This page covers the MicroProfile JWT 2.1 patterns commonly encountered in a JAX-RS API protected by bearer token, and shows how Cervantes expresses them — with no hot reflection, no third-party dependency, no ThreadLocal.
Securing a resource with @RolesAllowed
The jakarta.annotation.security.RolesAllowed annotation, on a method (or on the class), triggers the authorisation check. Precedence: the method wins over the class.
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
@Path("/orders")
@RolesAllowed("user") // class default — every endpoint requires at least "user"
public class OrderResource {
@GET
public List<Order> list() { /* inherits @RolesAllowed("user") */ }
@GET @Path("/admin/stats")
@RolesAllowed("admin") // more restrictive method — wins
public Stats stats() { /* … */ }
@GET @Path("/health")
@PermitAll // exposes the endpoint to everyone (anonymous included)
public String health() { /* … */ }
}
Expected behaviour:
-
Token missing or invalid + non-
@PermitAllendpoint → 401 Unauthorized. -
Valid token without the required role → 403 Forbidden.
-
Valid token with the required role → 200 OK (the method runs).
-
@DenyAll→ always 403, whatever the token.
Injecting the JsonWebToken principal
The validated principal is @RequestScoped and available through CDI anywhere in the request.
import jakarta.inject.Inject;
import org.eclipse.microprofile.jwt.JsonWebToken;
@Path("/me")
public class MeResource {
@Inject
JsonWebToken jwt;
@GET
@RolesAllowed("user")
public String describe() {
return "name=" + jwt.getName()
+ " iss=" + jwt.getIssuer()
+ " groups=" + jwt.getGroups()
+ " exp=" + jwt.getExpirationTime();
}
}
Without a token, the principal is an anonymous one — getName() == null, getGroups() empty, all claims null. No NPE to fear, but @RolesAllowed will already have returned 401 if the endpoint is not @PermitAll.
Injecting a single claim with @Claim
The @Claim("name") annotation lets you inject a typed value. Cervantes synthesises one bean per injection type found, through a Vauban BCE — resolution is static, with no hot reflection.
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import org.eclipse.microprofile.jwt.Claim;
import org.eclipse.microprofile.jwt.ClaimValue;
import java.util.Optional;
import java.util.Set;
public class ClaimSamples {
@Inject @Claim("email") String email; // String
@Inject @Claim("exp") Long exp; // Long
@Inject @Claim("email_verified") Boolean verified; // Boolean
@Inject @Claim("groups") Set<String> groups; // Set<String>
@Inject @Claim("email") Optional<String> emailOpt; // eager Optional
@Inject @Claim("email") ClaimValue<String> emailLazy; // lazy MP ClaimValue
@Inject @Claim("email") Provider<String> emailProv; // lazy CDI Provider
@Inject @Claim("raw_token") String rawToken; // the raw token
}
Supported types (excerpt — see Reference for the exhaustive list):
| Java type | Semantics |
|---|---|
|
Coercion from the claim’s JSON value. |
|
For array claims ( |
|
Raw value parsed by Champollion. |
|
Eager — resolved at injection time (current token). |
|
Lazy — re-reads the current request’s token on each call. |
|
On a primitive field ( |
Cookie transport instead of Authorization
MP JWT 2.1 §9.2.3 allows a cookie transport for apps that dislike Authorization.
# Switch to cookie mode
mp.jwt.token.header=Cookie
# Cookie name carrying the bearer (default: Bearer)
mp.jwt.token.cookie=session-jwt
On the JwtAuthenticationFilter side, extraction reads request.getCookies().get("session-jwt").getValue() instead of Authorization: Bearer ….
Dynamic JWKS with rotation
In production, the issuer publishes its keys at a known endpoint, e.g. via OpenID Connect Discovery:
mp.jwt.verify.publickey.location=https://issuer.example.com/.well-known/jwks.json
mp.jwt.verify.issuer=https://issuer.example.com
mp.jwt.verify.audiences=orders-api,billing-api
mp.jwt.verify.publickey.algorithm=RS256
Behaviour:
-
The first request triggers the fetch (lazy, not at startup).
-
The JWK Set is cached.
-
As long as a known
kidis used, no further network call. -
On an unknown
kid(rotation), Cervantes refreshes the JWK Set (limit: at most one refresh perminRefreshInterval). -
If the fetch fails, Cervantes falls back to the previous snapshot — no cascade of 503s.
JWE — receiving encrypted tokens
When the issuer encrypts the token (to hide claims from intermediate logs):
# Decryption private key — inline PKCS#8 PEM, or pointed to by .location
mp.jwt.decrypt.key.location=classpath:/META-INF/keys/decrypt-private.pem
# Expected key-wrapping algorithm (RSA-OAEP by default, also accepts RSA-OAEP-256)
mp.jwt.decrypt.key.algorithm=RSA-OAEP-256
The JweDecryptor unwraps the ephemeral symmetric key, decrypts the content (A256GCM), checks the authentication tag, then Cervantes validates the resulting JWS as usual.
Enforcing a maximum token age (token.age)
To reject tokens that are too old regardless of their exp:
# Reject any token whose iat is older than 300 seconds
mp.jwt.verify.token.age=300
The iat claim must then be present (unless mp.jwt.verify.requireiat=false).
Adjusting the clock-skew tolerance
When the issuer and the service do not share an atomic clock:
# Tolerance in seconds (default 60)
mp.jwt.verify.clock.skew=120
exp is valid up to exp + clockSkew, nbf is valid from nbf - clockSkew.
Reading an ad-hoc claim through JsonWebToken.getClaim
When the claim is non-standard and you do not want to create a dedicated @Claim injection:
@Inject JsonWebToken jwt;
@GET
public Response withTenant() {
String tenant = jwt.getClaim("https://example.com/tenant");
return Response.ok().header("X-Tenant", tenant).build();
}
getClaim returns null if absent. For the raw JSON value (useful when the claim is an object), getClaim returns a Champollion JsonObject.
Public endpoint on a mostly-protected resource
@PermitAll on a method allows a local exception:
@Path("/admin")
@RolesAllowed("admin")
public class AdminResource {
@GET @Path("/ping")
@PermitAll
public String ping() { return "pong"; } // accessible without a token
@GET @Path("/secret")
public String secret() { return "shh"; } // requires "admin"
}
Audit — log every call with the principal
Because JsonWebTokenContext is @RequestScoped, it can be injected into a ContainerResponseFilter to log who called what:
@Provider
@ApplicationScoped
public class AuditFilter implements ContainerResponseFilter {
@Inject JsonWebTokenContext tokenCtx;
@Override
public void filter(ContainerRequestContext req, ContainerResponseContext resp) {
JsonWebToken jwt = tokenCtx.getToken();
String name = jwt == null ? "anonymous" : jwt.getName();
log.info("{} {} → {} (caller={})", req.getMethod(), req.getUriInfo().getPath(), resp.getStatus(), name);
}
}