AuthN Filter Framework — Servlet-Layer Authentication
The org.apache.juneau.rest.server.auth package provides a pluggable servlet-filter authentication framework that runs at the servlet container layer — before Juneau's request routing — so a single URL can support multiple overlapping authentication schemes, short-circuit on the first success, and aggregate roles when more than one scheme succeeds.
See the guide: Secure an endpoint with JWT and OAuth
This framework is the natural peer of the AuthN Guards that ship in juneau-rest-server: guards run inside RestContext.handleCall(...) (op-level AuthZ); filters run before the servlet is reached (container-level AuthN).
At a glance
| Component | Module | Purpose |
|---|---|---|
AuthFilter (abstract) | juneau-rest-server | SPI base class with Optional<AuthResult> authenticate(req) contract. |
AuthResult | juneau-rest-server | Immutable value: resolved Principal + role set. |
AuthFilterChain | juneau-rest-server | Orchestrates multiple AuthFilter instances; first-success principal, role aggregation, all-failure 401. |
AuthenticatedRequestWrapper | juneau-rest-server | HttpServletRequestWrapper that surfaces filter-resolved principal and roles to downstream Juneau code. |
BearerTokenAuthFilter | juneau-rest-server | Filter impl reusing the TokenValidator SPI. |
ApiKeyAuthFilter | juneau-rest-server | Filter impl reusing the ApiKeyStore SPI. |
JettyServerComponent.addFilter(...) | juneau-microservice-jetty | New public filter-registration surface for Jetty microservices. |
Mental model: filter-time AuthN + op-time AuthZ
The framework preserves the clean separation between authentication (who is this?) and authorization (may they do this?):
HTTP request
│
▼
AuthFilterChain (servlet filter — runs before RestServlet)
│ calls authenticate() on matching filters
│ wraps request with AuthenticatedRequestWrapper on first success
▼
RestServlet.service(wrappedReq, ...)
│
▼
RestGuardList (RoleBasedRestGuard, etc.)
│ reads req.isUserInRole(...) from AuthenticatedRequestWrapper
▼
@RestOp handler
│ @Auth Principal p ← reads RestServerConstants.PRINCIPAL_ATTR
▼
AuthenticatedRequestWrapper bridges the two layers: it overrides getUserPrincipal(), isUserInRole(String), getRemoteUser(), and getAttribute(PRINCIPAL_ATTR) so both RoleBasedRestGuard and the @Auth arg resolver see the filter-resolved identity with zero changes.
Two-layer architecture
Layer 1 — Standalone filters
Each concrete AuthFilter subclass (BearerTokenAuthFilter, ApiKeyAuthFilter, any custom impl) is also a jakarta.servlet.Filter by itself. Use the standalone mode when a single auth scheme is sufficient:
// Standalone bearer-token filter registered directly with JettyServerComponent:
jsc.addFilter(
BearerTokenAuthFilter.create().validator(jwtValidator).build(),
"/*");
In standalone mode, AuthFilter.doFilter(req, resp, chain):
- Calls
authenticate(req). - On success: wraps the request and passes it through.
- On
Optional.empty(): passes the request through unchanged (no credentials for this filter). - On
AuthenticationException: writes a401response withWWW-Authenticatechallenge.
Layer 2 — AuthFilterChain orchestrator
Use AuthFilterChain when multiple auth schemes overlap on the same URL:
@Bean
public AuthFilterChain authFilters(BeanStore bs) {
return AuthFilterChain.create(bs)
.append(BearerTokenAuthFilter.create()
.validator(jwtValidator)
.build())
.append(ApiKeyAuthFilter.create()
.store(apiKeyStore)
.build())
// /sso/** accepts only SAML (provided by juneau-rest-server-auth-saml, a future sibling module):
.append(mySamlFilter, "/sso/*")
.build();
}
JettyServerComponent auto-mounts the AuthFilterChain bean at /* when it is present in the BeanStore — no explicit addFilter(...) call needed for the common case.
Optional<AuthResult> three-state contract
AuthFilter.authenticate(req) returns one of three states:
| State | Meaning |
|---|---|
Optional.empty() | Filter does not apply (no recognizable credentials). Chain continues. |
Optional.of(AuthResult) | Authentication succeeded. |
throw AuthenticationException | Credentials were present but invalid (bad token, revoked key, etc.). |
This distinction matters for the chain's failure-aggregation logic:
- If no filter applies (all return empty) the request passes through unchanged — public endpoints need no credentials.
- If at least one filter actively rejected (threw) the request, the chain sends
401with aggregatedWWW-Authenticatechallenges.
Pattern matching
Each filter in the chain can be scoped to a URL pattern via append(filter, pattern). Patterns are parsed once at build time via UrlPathMatcher.of(String). Supported forms:
| Pattern | Matches |
|---|---|
| (none) | All paths |
/* | All paths |
/api/* | /api/, /api/users, /api/users/123, … |
/foo/{id}/* | /foo/abc/anything, … |
*.json | Any path ending in .json |
Filters whose patterns do not match the request path are not called — they contribute neither successes nor failures.
Role aggregation semantics
When multiple filters match and succeed, the chain aggregates roles across all successful results:
- The first successful filter's
Principalwins for identity (deterministic ordering by registration order). - All successful filters' role sets are unioned into a single
Set<String>. AuthenticatedRequestWrapper.isUserInRole(String)checks this union.
Example: Bearer JWT grants user, API key grants admin. Both succeed on the same request:
getUserPrincipal().getName()returns the Bearer principal's name.isUserInRole("user")→true,isUserInRole("admin")→true.RoleBasedRestGuard("user|admin")→ passes.
Registration via @Bean
The preferred pattern mirrors the @Bean RestGuardList idiom. Since JettyMicroservice is final, place the @Bean method in a separate @Configuration class:
@Configuration
public class MyAppConfig {
@Bean
public AuthFilterChain authFilters(BeanStore bs) {
return AuthFilterChain.create(bs)
.append(BearerTokenAuthFilter.create()
.pattern("/api/*")
.validator(jwtValidator)
.build())
.append(ApiKeyAuthFilter.create()
.pattern("/api/*")
.store(apiKeyStore)
.build())
.build();
}
}
Then wire the configuration class at startup:
var beanStore = new BasicBeanStore();
beanStore.addBean(Servlet.class, new RootResources());
JettyMicroservice.run(args, beanStore, true, JettyConfiguration.class, MyAppConfig.class);
JettyServerComponent.onStart(...) scans the BeanStore for an AuthFilterChain bean and registers it at /* before any servlet is mounted.
Direct filter registration
For simpler deployments or when bypassing the chain, register standalone filters directly:
// In a microservice subclass or test setup:
getBeanStore().getBean(JettyServerComponent.class).ifPresent(jsc ->
jsc.addFilter(BearerTokenAuthFilter.create().validator(myValidator).build(), "/api/*")
);
Both addFilter(Filter, String) (single pattern) and addFilter(Filter, String...) (multiple patterns) are available.
Worked examples
Bearer + API-key with pattern overlap
// /api/* accepts Bearer JWT or API key — first to succeed wins; roles aggregate when both do.
AuthFilterChain.create(bs)
.append(BearerTokenAuthFilter.create()
.pattern("/api/*")
.validator(jwtValidator)
.build())
.append(ApiKeyAuthFilter.create()
.pattern("/api/*")
.store(apiKeyStore)
.build())
.build();
Pattern-disjoint filters
// /api/* → JWT only; /internal/* → API key only; no overlap.
AuthFilterChain.create(bs)
.append(BearerTokenAuthFilter.create()
.pattern("/api/*")
.validator(jwtValidator)
.build())
.append(ApiKeyAuthFilter.create()
.pattern("/internal/*")
.store(internalKeyStore)
.build())
.build();
Failure-aggregation response shape
When both filters apply and both reject, the response is:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", ApiKey realm="api"
Bearer token missing; API key missing
BearerTokenAuthFilter — bearer tokens
Mirrors BearerTokenGuard extraction logic. Reads Authorization: Bearer <token>, delegates to a TokenValidator. Extracts roles from a ClaimsPrincipal claim (default claim name: "roles").
Builder methods: validator(TokenValidator) (required), realm(String) (default: "api"), rolesClaim(String) (default: "roles").
If absent or non-Bearer scheme → Optional.empty().
ApiKeyAuthFilter — API keys
Mirrors ApiKeyGuard extraction logic. Reads the key from a configurable source (header / query / cookie), delegates to an ApiKeyStore. Extracts roles from ClaimsPrincipal claims.
Builder methods: store(ApiKeyStore) (required), fromHeader(String) (default: X-API-Key), fromQuery(String), fromCookie(String), realm(String) (default: "api"), rolesClaim(String) (default: "roles").
If key absent or blank → Optional.empty().
Spring Boot note
Spring Security's SecurityFilterChain is the recommended authentication path for Spring Boot users. BearerTokenAuthFilter / ApiKeyAuthFilter can be registered via Spring Boot's FilterRegistrationBean if desired, but the AuthFilterChain @Bean auto-mount is wired for JettyServerComponent only. Do not run both frameworks on the same request path.
See also
- Secure an endpoint with JWT and OAuth: a guided walkthrough.
- REST Authenticator — Resource-Level Authentication — the resource-layer peer that resolves identity + roles inside Juneau dispatch, reusing these same filters.
- AuthN Guards — Bearer / API-Key / JWT — the op-level guards that compose with this framework.
- SAML 2.0 AuthN Support — the opt-in
juneau-rest-server-auth-samlmodule that adds aSamlAuthFilterimplementation. - OAuth 2.0 / OIDC AuthN Support — the opt-in
juneau-rest-server-auth-oauthmodule that adds anOAuthFilter+ introspection / OIDC discovery / grant-flow helpers. - OIDC Relying Party Login — the opt-in
juneau-rest-server-auth-oidc-rpmodule whoseOidcSessionAuthFilterresolves an OIDC login-session cookie into aClaimsPrincipal. AuthFilterChainAuthFilterBearerTokenAuthFilterApiKeyAuthFilterJettyServerComponent