How to validate bearer JWT access tokens

OAuth 2.0 leaves the design of access tokens in terms of encoding and validation up to implementers. These can be minted as JSON Web Tokens (JWT).

The Connect2id server, for example, can mint access tokens that are RSA-signed JWTs. These can be validated quickly and efficiently with the public key for the JWT. Cryptographic validation can be faster and more resilient than having identifier-based access which the resource server inspects by making a network query to the OAuth 2.0 server in order to retrieve the linked authorisation information.

JWT validation framework

The Nimbus JOSE+JWT library includes a simple framework to take care of the necessary steps to validate a JWT.

What are these steps?

  1. JWT parsing -- The access token string is parsed as a JWT.
  2. Type check -- Checks the "typ" (type) header parameter which indicates the JWT type or usage. The Connect2id server sets it to "at+jwt" for an access token.
  3. Algorithm check -- The JWS algorithm specified in the JWT header is checked whether it matches the agreed / expected one (e.g. RS256 for RSA PKCS #1 signature with SHA-256). If a token with an unexpected algorithm is received it is rejected. This prevents downgrade and other attacks that may become possible if tokens with any JOSE algorithm are accepted.
  4. Signature check -- The digital signature is verified by trying an appropriate public key from the server JWK set. The used key is typically identified by the "kid" (key ID) header parameter.
  5. JWT claims check -- The JWT claims set is validated, for example to ensure the token is not expired and matches 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 document published by the Connect2id server (requires Nimbus JOSE+JWT v8.2+):

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 a HTTP header like
// Authorization: Bearer eyJraWQiOiJDWHVwIiwidHlwIjoiYXQrand0IiwiYWxnIjoi...
String accessToken =
    "eyJraWQiOiJDWHVwIiwidHlwIjoiYXQrand0IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJib2IiLCJzY" +
    "3AiOlsib3BlbmlkIiwiZW1haWwiXSwiY2xtIjpbIiFCZyJdLCJpc3MiOiJodHRwczpcL1wvZGVtby5jM" +
    "mlkLmNvbVwvYzJpZCIsImV4cCI6MTU3MTMxMjAxOCwiaWF0IjoxNTcxMzExNDE4LCJ1aXAiOnsiZ3Jvd" +
    "XBzIjpbImFkbWluIiwiYXVkaXQiXX0sImp0aSI6ImJBT1BiNWh5TW80IiwiY2lkIjoiMDAwMTIzIn0.Q" +
    "hTAdJK8AbdJJhQarjOz_qvAINQeWJCIYSROVaeRpBfaOrTCUy5gWRf8xrpj1DMibdHwQGPdht3chlAC8" +
    "LGbAorEu0tLLcOwKl4Ql-o30Tdd5QhjNb6PndOY89NbQ1O6cdOZhvV4XB-jUAXi3nDgCw3zvIn2348Va" +
    "2fOAzxUvRs2OGsEDl5d9cmL3e68YqSh7ss12y9oBDyEyz8Py7dtXgt6Tg67n9WlEBG0r4KloGDBdbCCZ" +
    "hlEyURkHaE-3nUcjwd-CEVeqWPO0bsLhwto-80j8BtsfD649GnvaMb9YdbdYhTTs-MkRUQpQIZT0s9oK" +
    "uzKayvZhk0c_0FoSeW7rw";

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

// Set the required "typ" header "at+jwt" for access tokens issued by the
// Connect2id server, may not be set by other servers
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, published at a well-known URL. The RemoteJWKSet
// object caches the retrieved keys to speed up subsequent look-ups and can
// also handle key-rollover
JWKSource<SecurityContext> keySource =
    new RemoteJWKSet<>(new URL("https://demo.c2id.com/jwks.json"));

// 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 issued by the Connect2id
// server, may differ with other servers
jwtProcessor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier(
    new JWTClaimsSet.Builder().issuer("https://demo.c2id.com").build(),
    new HashSet<>(Arrays.asList("sub", "iat", "exp", "scp", "cid", "jti"))));

// Process the token
SecurityContext ctx = null; // optional context parameter, not required here
JWTClaimsSet claimsSet = jwtProcessor.process(accessToken, ctx);

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

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). For that the JWT processor must 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 RemoteJWKSet 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<?> jwkSource = new RemoteJWKSet<>(
            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.

Failover JWK set URL

Configuring a second failover JWK set URL for the RemoteJWKSet in case the first one times our or becomes otherwise unavailable (e.g. with a HTTP 404 or HTTP 500) is easy:

URL failoverURL = new URL("https://backup.c2id.com/jwks.json");
JWKSource<?> failoverJWKSource = new RemoteJWKSet<>(failoverURL);

URL mainURL = new URL("https://c2id.com/jwks.json");
JWKSource<?> jwkSource = new RemoteJWKSet<>(mailURL, failoverJWKSetSource);

The failover is based on the JWKSource interface, which permits implementation of arbitrary strategies to work around network and host failures.

This feature became available in version 9.17.

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/c2id").build(),

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

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