Skip to main content

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​

ComponentModulePurpose
AuthFilter (abstract)juneau-rest-serverSPI base class with Optional<AuthResult> authenticate(req) contract.
AuthResultjuneau-rest-serverImmutable value: resolved Principal + role set.
AuthFilterChainjuneau-rest-serverOrchestrates multiple AuthFilter instances; first-success principal, role aggregation, all-failure 401.
AuthenticatedRequestWrapperjuneau-rest-serverHttpServletRequestWrapper that surfaces filter-resolved principal and roles to downstream Juneau code.
BearerTokenAuthFilterjuneau-rest-serverFilter impl reusing the TokenValidator SPI.
ApiKeyAuthFilterjuneau-rest-serverFilter impl reusing the ApiKeyStore SPI.
JettyServerComponent.addFilter(...)juneau-microservice-jettyNew 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 a 401 response with WWW-Authenticate challenge.

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:

StateMeaning
Optional.empty()Filter does not apply (no recognizable credentials). Chain continues.
Optional.of(AuthResult)Authentication succeeded.
throw AuthenticationExceptionCredentials 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 401 with aggregated WWW-Authenticate challenges.

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:

PatternMatches
(none)All paths
/*All paths
/api/*/api/, /api/users, /api/users/123, …
/foo/{id}/*/foo/abc/anything, …
*.jsonAny 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 Principal wins 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​