security-infrastructure
Cloudflare Access Java JWT Validation Patterns
Validating Cloudflare Access identity in Java services without over-trusting the client — and locking the origin so the gate cannot be bypassed.
Carlos Ulloque · 5/30/2026 · 3 min read
- cloudflare-access
- java
- jwt
- zero-trust
Cloudflare Access sits in front of the application and issues a signed JWT for each verified user. The origin still has to validate that JWT on every request. Until it does, the trust boundary sits wherever the token is actually checked — and if nothing checks it, Cloudflare Access provides no protection at the application layer.
What follows is the validation in Java. The checks are the same whether it runs in Spring Boot or a plain servlet.
What the JWT contains
Cloudflare Access JWTs carry:
aud— your application’s audience tag (set in the Access application config)iss— the Cloudflare team domain (https://<your-team>.cloudflareaccess.com)email— the verified identity of the userexp,iat,nbf— standard time claims
The JWT is signed with a key pair rotated by Cloudflare. Public keys are available at:
https://<your-team>.cloudflareaccess.com/cdn-cgi/access/certs
Dependency
Any JOSE library does the job; the examples here use Nimbus JOSE + JWT.
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.37.3</version>
</dependency>
Fetching the public key set
Cloudflare publishes the signing keys at a JWKS endpoint. Cache them — fetching on every request puts a network call, and a new failure mode, directly in the auth path:
@Component
public class CloudflareAccessKeySource {
private final String jwksUri;
private JWKSet cachedKeys;
private Instant cacheExpiry = Instant.MIN;
public CloudflareAccessKeySource(@Value("${cloudflare.access.jwks-uri}") String jwksUri) {
this.jwksUri = jwksUri;
}
public synchronized JWKSet getKeys() throws Exception {
if (Instant.now().isAfter(cacheExpiry)) {
cachedKeys = JWKSet.load(new URL(jwksUri));
cacheExpiry = Instant.now().plusSeconds(3600);
}
return cachedKeys;
}
}
Validation logic
@Service
public class AccessTokenValidator {
private final CloudflareAccessKeySource keySource;
private final String audience;
private final String issuer;
public AccessTokenValidator(
CloudflareAccessKeySource keySource,
@Value("${cloudflare.access.audience}") String audience,
@Value("${cloudflare.access.issuer}") String issuer) {
this.keySource = keySource;
this.audience = audience;
this.issuer = issuer;
}
public JWTClaimsSet validate(String token) throws Exception {
SignedJWT jwt = SignedJWT.parse(token);
JWKSet keys = keySource.getKeys();
JWK key = keys.getKeyByKeyId(jwt.getHeader().getKeyID());
if (key == null) {
throw new SecurityException("Unknown key ID");
}
JWSVerifier verifier = new RSASSAVerifier((RSAKey) key);
if (!jwt.verify(verifier)) {
throw new SecurityException("JWT signature invalid");
}
JWTClaimsSet claims = jwt.getJWTClaimsSet();
if (!claims.getAudience().contains(audience)) {
throw new SecurityException("Audience mismatch");
}
if (!issuer.equals(claims.getIssuer())) {
throw new SecurityException("Issuer mismatch");
}
if (new Date().after(claims.getExpirationTime())) {
throw new SecurityException("Token expired");
}
return claims;
}
}
Extracting the token from the request
Cloudflare Access delivers the JWT in the Cf-Access-Jwt-Assertion header:
String token = request.getHeader("Cf-Access-Jwt-Assertion");
if (token == null || token.isBlank()) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
Take the assertion only from the Cf-Access-Jwt-Assertion header. A token arriving in a cookie or query parameter is attacker-controlled input, not proof of identity.
Spring Security filter
Wire the validator into a OncePerRequestFilter, ahead of the rest of the security chain. Any failure — missing header, bad signature, wrong audience — ends the request as 401; the filter never falls through to the application:
@Component
public class CloudflareAccessFilter extends OncePerRequestFilter {
private final AccessTokenValidator validator;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String token = request.getHeader("Cf-Access-Jwt-Assertion");
if (token == null) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
try {
JWTClaimsSet claims = validator.validate(token);
String email = claims.getStringClaim("email");
SecurityContextHolder.getContext().setAuthentication(
new PreAuthenticatedAuthenticationToken(email, null, List.of())
);
} catch (Exception e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
chain.doFilter(request, response);
}
}
What this does not protect against
Validating the JWT only proves what the token says. It does nothing about the paths around it:
- Direct-to-origin requests that never pass through Cloudflare. If the origin IP is reachable, the JWT check is optional from an attacker’s point of view — restrict port 443 to Cloudflare’s ranges, or remove the public port entirely with Tunnel.
- Key rotation gaps. A cache TTL that is too long keeps trusting keys Cloudflare has already rotated out; keep it short enough to converge within minutes.
- Replay inside the validity window. A leaked token stays valid until it expires — roughly an hour by default. Acceptable for most cases, but it is a window, not zero.