How to validate bearer JWT access tokens

OAuth 2.0 leaves the choice how to encode access tokens up to implementers. The signed JSON Web Token (JWT) has become the most popular encoding for self-contained tokens.

The Connect2id server for instance mints access JWTs signed with the RSA, EC or EdDSA family of algorithms. The token consumers (protected resource servers) verify their signatures using a designated public key, made available for download at a server endpoint.

JWT validation framework

The Nimbus JOSE+JWT library comes with a framework capable of performing all necessary steps to validate a JWT:

  1. JWT parsing -- The access token is parsed as a signed JWT, or signed then encrypted JWT.

  2. Type check -- The optional typ (type) header parameter of the JWT is checked. Explicit JWT typing is a recommended security measure to prevent cross-JWT confusion. For access JWTs the standard type is at+jwt.

  3. Algorithm check -- The JWS algorithm specified in the JWT header must match the agreed / expected one (e.g. "RS256" for an RSA PKCS #1 signature with SHA-256). If a token's JWS algorithm is unexpected it is rejected. This is to prevent downgrade and other attacks.

  4. Signature check -- The digital signature is verified by trying an appropriate public key from the server JWK set. The key is typically selected by the kid (key ID) header parameter.

  5. JWT claims check -- The JWT claims set is validated, to ensure the token has not expired and has the expected issuer, audience and other claims.

If any of these checks fails the token is considered invalid and the request must be denied.

Here is an example Java code to set up a ConfigurableJWTProcessor which obtains the necessary public RSA keys from a JSON Web Key (JWK) set document published by the Connect2id server (requires Nimbus JOSE+JWT v9.28+):

import com.nimbusds.jose.*;
import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jwt.*;
import com.nimbusds.jwt.proc.*;

// The access token to validate, typically submitted with an HTTP header like
// Authorization: Bearer eyJraWQiOiJDWHVwIiwidHlwIjoiYXQrand0IiwiYWxnIjoiU...
String accessToken =
    "eyJraWQiOiJDWHVwIiwidHlwIjoiYXQrand0IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJhbGljZSIsI" +
    "nNjcCI6WyJvcGVuaWQiLCJlbWFpbCJdLCJjbG0iOlsiIUJnIl0sImlzcyI6Imh0dHBzOi8vZGVtby5jM" +
    "mlkLmNvbSIsImV4cCI6MTY5MDA0MDc1OCwiaWF0IjoxNjkwMDQwMTU4LCJ1aXAiOnsiZ3JvdXBzIjpbI" +
    "mFkbWluIiwiYXVkaXQiXX0sImp0aSI6InNjMmRodXRNRzFBIiwiY2lkIjoiMDAwMTIzIn0.QmLMSn4pn" +
    "wGwc04kbhr-CFLHnd4BcDBAtpNLVbf3EymSyRLGcAL3wgdE-V2tMHWO1r2Q8feAr2H8R4AUrkRx2eiWT" +
    "zrTxGLU_T1GVQ2s7nzN7BzLnKxo8y9tArUypq_25rBNNkES6IF2Mu2FwBA8eyoWodQV7xl5bmOBDuZ4l" +
    "09HCdE9sz8PAMt6itQAv-nPsebUAL9vB5r2j8uB_84Uwa2RxtpEYrH36uYryPaW5lQbdkaFm8RA_Dd-t" +
    "PsP5a5yqeQLXOHif1UYYK6S7oEETsmzP7IZyiEaJ5noYesuvmnDHS352ffSGW0hQC84wDJE85gl-jn4l" +
    "d-5DmI3dWeS9A";

// Create a JWT processor for the access tokens
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();

// Set the required "typ" header "at+jwt" for access tokens
jwtProcessor.setJWSTypeVerifier(
    new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt")));

// The public RSA keys to validate the signatures will be sourced from the
// OAuth 2.0 server's JWK set URL. The key source will cache the retrieved
// keys for 5 minutes. 30 seconds prior to the cache's expiration the JWK
// set will be refreshed from the URL on a separate dedicated thread.
// Retrial is added to mitigate transient network errors.
JWKSource<SecurityContext> keySource = JWKSourceBuilder
    .create(new URL("https://demo.c2id.com/jwks.json"))
    .retrying(true)
    .build();

// The expected JWS algorithm of the access tokens (agreed out-of-band)
JWSAlgorithm expectedJWSAlg = JWSAlgorithm.RS256;

// Configure the JWT processor with a key selector to feed matching public
// RSA keys sourced from the JWK set URL
JWSKeySelector<SecurityContext> keySelector = new JWSVerificationKeySelector<>(
    expectedJWSAlg,
    keySource);
jwtProcessor.setJWSKeySelector(keySelector);

// Set the required JWT claims for access tokens
jwtProcessor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
    new JWTClaimsSet.Builder().issuer("https://demo.c2id.com").build(),
    new HashSet<>(Arrays.asList(
        JWTClaimNames.SUBJECT,
        JWTClaimNames.ISSUED_AT,
        JWTClaimNames.EXPIRATION_TIME,
        "scp",
        "cid",
        JWTClaimNames.JWT_ID))));

// Process the token
SecurityContext ctx = null; // optional context parameter, not required here
JWTClaimsSet claimsSet;
try {
    claimsSet = jwtProcessor.process(accessToken, ctx);
} catch (ParseException | BadJOSEException e) {
    // Invalid token
    System.err.println(e.getMessage());
    return;
} catch (JOSEException e) {
    // Key sourcing failed or another internal exception
    System.err.println(e.getMessage());
    return;
}

// Print out the token claims set
System.out.println(claimsSet.toJSONObject());

Use of the JWKSourceBuilder and its options to retrieve and cache the server's keys is explained in a separate article.

The printed validated access token claims set:

{
  "sub" : "alice",
  "cid" : "000123",
  "iss" : "https://demo.c2id.com",
  "exp" : 1460345736,
  "scp" : ["openid","email","profile"],
  "clm" : ["!5v8H"],
  "uip" : {"groups":["admin","audit"]}
}

Encrypted tokens

The JWT processing framework can also handle tokens which are encrypted after signing (or just encrypted). JWT processor must then be configured with an appropriate selector for the JWE decryption keys.

Example setup to handle tokens that are directly encrypted with a shared AES key:

// The AES key, obtained from a Java keystore, etc.
SecretKey secretKey = null;

// The expected JWE algorithm and method
JWEAlgorithm expectedJWEAlg = JWEAlgorithm.DIR;
EncryptionMethod expectedJWEEnc = EncryptionMethod.A128GCM;

// The JWE key source
JWKSource jweKeySource = new ImmutableSecret(secretKey);

// Configure a key selector to handle the decryption phase
JWEKeySelector jweKeySelector =
    new JWEDecryptionKeySelector(expectedJWEAlg, expectedJWEEnc, jweKeySource);

jwtProcessor.setJWEKeySelector(jweKeySelector);

JWK set URL timeouts

The basic JWKSourceBuilder constructor will create an internal HTTP resource retriever with default HTTP connect and read timeouts and a default HTTP entity size limit.

These defaults can be overridden in two ways:

  • By passing a configured ResourceRetriever, for example:

    int httpConnectTimeoutMs = 5_000;
    int httpReadTimeoutMs = 5_000;
    int httpSizeLimitBytes = 100_000;
    
    JWKSource<SecurityContext> keySource = JWKSourceBuilder.create(
      new URL("https://demo.c2id.com/jwks.json"),
      new DefaultResourceRetriever(
          httpConnectTimeoutMs,
          httpReadTimeoutMs,
          httpSizeLimitBytes));
    
  • By setting the following Java system properties (suitable when there is no direct way to construct the RemoteJWKSet, can occur in frameworks that use this library internally):

    Setting a HTTP connect timeout of 5 seconds:

    com.nimbusds.jose.jwk.source.RemoteJWKSet.defaultHttpConnectTimeout=5000
    

    Setting a HTTP read timeout of 2.5 seconds:

    com.nimbusds.jose.jwk.source.RemoteJWKSet.defaultHttpReadTimeout=2500
    

    Setting a HTTP entity size limit of 100 kilobytes:

    com.nimbusds.jose.jwk.source.RemoteJWKSet.defaultHttpSizeLimit=100_000
    

    These Java system properties are supported since v9.16.

How to plug an alternative JWK source

The example above used a JWK set URL to feed the key selector. Other types of key sources that are supported of the box:

  • ImmutableJWKSet -- to specify a set of JWK candidates directly by value.

    import java.io.File;
    import com.nimbusds.jose.jwk.*;
    import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
    
    // Load JWK set from JSON file, etc.
    JWKSet jwkSet = JWKSet.load(new File("/var/server/jwkset.json");
    
    // Create JWK source backed by a JWK set
    JWKSource keySource = new ImmutableJWKSet(jwkSet);
    
  • ImmutableSecret -- to specify a singleton JWK that is symmetric key; can be used for HMAC verification, direct AES decryption, password-based decryption and other cases that require a secret key.

    import java.security.SecureRandom;
    import com.nimbusds.jose.jwk.source.ImmutableSecret;
    
    // Generate secret
    byte[] secret = new byte[32];
    new SecureRandom().nextBytes(secret);
    
    // Create JWK source backed by a singleton secret key
    JWKSource keySource = new ImmutableSecret(secret);
    

You can implement your own custom JWKSource, for example based on a Java keystore or some database.

JWT claims validation

The DefaultJWTClaimsVerifier can be configured to perform all necessary checks to determine if the JWT claims are legal.

  • Expected audience -- If the JWT has an audience (aud) (recommended) that it includes the identifier for the resource server.

  • Exact match claims -- JWT claims which must be present in the JWT and their values must match exactly. For example issuer (iss).

  • Required claims -- The names of claims that must be present in the JWT. These can be for instance include expiration time (exp), subject (sub), client application (client_id) and scope (scope). This list automatically includes the names of the expected audience (if set) and all exact match claims.

  • Prohibited claims -- List of the names of JWT claims that must not be present. This list can be used to prevent accidental or malicious passing of another JWT type, especially of the typ (type) header is not used to convey that information.

JWTClaimsSetVerifier<C> claimsVerifier = new DefaultJWTClaimsVerifier(

    // expected audience
    "https://rs.example.com",

    // exact match claims
    new JWTClaimsSet.Builder().issuer("https://demo.c2id.com").build(),

    // names of required claims
    new HashSet<>(Arrays.asList("exp", "sub", "client_id", "scope")),

    // names of prohibited claims
    Collections.singleton("nonce")
);