In this article
April 16, 2026
April 16, 2026

How to handle JWT in Java

Everything you need to know to implement and validate JWTs securely in Java: from signing to verifying with JWKS, with code examples and best practices throughout.

Java has been the backbone of enterprise authentication for decades. Whether you are building a Spring Boot API, a microservices mesh, or a backend that serves mobile clients, JWTs are likely part of your authentication and authorization story. But Java's JWT ecosystem is broader and more fragmented than most languages, with multiple competing libraries, tight integration with the Java Cryptography Architecture (JCA), and framework-level abstractions in Spring Security that can obscure what is actually happening.

This guide walks through everything you need to know to safely consume, validate, and work with JWTs in Java, including RS256 and HS256 verification, JWKS, key rotation, Spring Security integration, and common pitfalls. Let's dive right in.

!!Need to inspect a token? Use the WorkOS JWT Debugger to decode and inspect your JWTs directly in the browser. It's a quick way to verify your token's iss, aud, sub, and other claims while debugging.!!

JWT 101

A JSON Web Token is a compact, URL-safe token format used to securely transmit information between systems. At a high level, a JWT lets one system make a signed statement about a user or service, and lets another system verify that statement without needing to look anything up in a database.

They are typically used to indicate a user's identity and/or assert permissions and roles.

A JWT is composed of three parts, each Base64URL-encoded and separated by dots:

  
header.payload.signature
  

Header

The header contains metadata about the token, most importantly the signing algorithm used to create the signature (e.g., HMAC, RSA, or ECDSA). This tells the verifier how the token was signed and how it should be validated.

A typical header before encoding:

  
{
  "alg": "RS256",
  "typ": "JWT"
}
  

In this example, alg is set to RS256, representing RSA with SHA-256, and typ identifies this as a JWT.

Payload

The payload contains the actual data the token encodes. These data points are called claims.

Claims are pieces of information about the subject of the token and additional context about how it should be used. Some claims are registered and standardized, like iss, sub, aud, and exp (for the full list check the JWT claims registry). Others are custom and application-specific.

Example payload:

  
{
  "sub": "550e8400-e29b-41d4-a716-446655440000",
  "email": "hgranger@hogwarts.example",
  "roles": ["admin", "editor"],
  "iat": 1716239022,
  "iss": "your-saas-app",
  "aud": "your-api",
  "exp": 1716242622
}
  

It is important to note that the payload is not encrypted. Anyone who has the token can decode it and read the claims. Do not put passwords, secrets, or high-risk PII in JWT payloads.

Signature

The signature ensures the token's integrity and confirms that it was issued by a trusted source. It is created by hashing the Base64URL-encoded header and payload with a secret key (for symmetric algorithms like HS256) or a private key (for asymmetric algorithms like RS256). The resulting hash is then Base64URL-encoded and appended to the token.

When a JWT is received, the verifier recomputes the signature using the appropriate key and compares it to the signature included in the token. If they do not match, the token has been tampered with and must be rejected.

JWTs are protected via JSON Web Signature (JWS). JWS is used to share data between parties when confidentiality is not required, because the claims within a JWS can be read by anyone (they are simply Base64URL-encoded). The signature provides authentication, not encryption. Some of the cryptographic algorithms JWS uses are HMAC, RSA, and ECDSA.

Algorithm Description Use case
HS256 HMAC with shared secret Simple systems, internal services
RS256 RSA with public/private keys Authorization servers, external IdPs
ES256 ECDSA with elliptic-curve keys Modern IdPs, compact signatures

JWT libraries for Java

Java has several JWT libraries. The most widely used are:

  • Nimbus JOSE + JWT (com.nimbusds:nimbus-jose-jwt) is the most comprehensive. It is the library that Spring Security uses under the hood for its OAuth2 resource server JWT support. It provides full coverage of the JOSE specification suite (JWS, JWE, JWK, JWKS), built-in JWKS endpoint fetching with caching, and clean integration with the Java Cryptography Architecture. It is actively maintained and has the broadest adoption in the Java ecosystem.
  • JJWT (io.jsonwebtoken:jjwt) is another popular option with a fluent builder API. It is commonly seen in tutorials and blog posts but has seen slower release cadence compared to Nimbus.

This guide uses Nimbus JOSE + JWT because it covers the widest range of use cases, is what Spring Security relies on, and provides built-in JWKS support that the other libraries require separate dependencies for.

Maven

  
<dependency>
    <groupId>com.nimbusds</groupId>
    <artifactId>nimbus-jose-jwt</artifactId>
    <version>10.0.2</version>
</dependency>
  

Gradle

  
implementation 'com.nimbusds:nimbus-jose-jwt:10.0.2'
  

Generating your keys

First, you need a set of cryptographic keys to sign your tokens.

In this tutorial, we will be using RS256. This asymmetric algorithm requires two keys: a private key to sign the token and a public key to verify it. If you already have them, move along to the next section.

!!Asymmetric algorithms use a pair of public and private keys to sign and verify the tokens. They are more secure, scalable, and better for distributed systems but also more resource-intensive and complex. For more on the various algorithms see Which algorithm should you use to sign JWTs?!!

There are many ways to generate your keys. You could generate them using OpenSSL and save them as raw PEM files that your code would read:

  
# Generate private key
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048

# Extract the public key
openssl rsa -pubout -in private_key.pem -out public_key.pem
  

However, this is not a best practice. Instead, you should use JSON Web Key Sets (JWKS), especially in distributed or cloud environments.

!!JWKS vs PEM: JWKS simplifies key rotation by allowing services to fetch the latest keys from a central endpoint, making updates easier and reducing the risk of errors. PEM files require manual distribution and updates, which can be cumbersome in large systems. JWKS centralizes key distribution, ensuring that all services or clients always have the correct keys without constant manual updates.!!

If you are using a third-party identity provider (like WorkOS), they automatically generate and expose a JWKS endpoint for you. This allows clients to dynamically fetch the public keys needed for JWT verification without you having to manage the keys manually. WorkOS offers a public JWKS endpoint:

  
https://api.workos.com/sso/jwks/your-client_id
  

The response looks like this:

  
{
  "keys": [
    {
      "alg": "RS256",
      "kty": "RSA",
      "use": "sig",
      "x5c": [
        "MIIDQj3DQEBCwUA..."
      ],
      "n": "0vx7agoebGc...eKnNs",
      "e": "AQAB",
      "kid": "key_013456789",
      "x5t#S256": "ZjQzYjI0OT...NmNjU0"
    }
  ]
}

  

Clients and APIs can use this endpoint to retrieve the public keys needed to validate JWTs signed by WorkOS. Key rotation, expiration, and distribution are handled automatically by the provider.

If you are not using a third-party identity provider and want to create and manage your own JWKS in Java, you will need to:

  1. Generate a key pair (public and private keys). Java's java.security.KeyPairGenerator provides this natively through the JCA.
  2. Create a JWKS endpoint. Expose the public keys at a well-known URL (/.well-known/jwks.json) that clients and services can use to validate JWTs.
  3. Handle key rotation and management. Periodically generate new key pairs and update the JWKS. Use a key identifier (kid) to distinguish between active and retired keys.
  4. Secure your private keys. Never expose private keys through your API or any public endpoint. Store them in a secure EKM like WorkOS Vault, an HSM, or at minimum an encrypted file with restricted access.

!!If you need something fast for a proof-of-concept, you can use a tool like mkjwk.org to generate a JWK.!!

Generating an RSA key pair and JWK in Java

Here is how to generate an RSA key pair using Nimbus and export the public key as a JWK:

  
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.JWKSet;
import java.security.KeyPairGenerator;
import java.security.KeyPair;
import java.security.interfaces.RSAPublicKey;
import java.security.interfaces.RSAPrivateKey;
import java.util.UUID;

public class KeyGenerator {
    public static void main(String[] args) throws Exception {
        // Generate an RSA key pair using the JCA
        KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
        gen.initialize(2048);
        KeyPair keyPair = gen.generateKeyPair();

        // Wrap it in a Nimbus RSAKey with a unique kid
        RSAKey jwk = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
                .privateKey((RSAPrivateKey) keyPair.getPrivate())
                .keyID(UUID.randomUUID().toString())
                .build();

        // The public JWK (safe to expose at your JWKS endpoint)
        RSAKey publicJwk = jwk.toPublicJWK();
        JWKSet jwks = new JWKSet(publicJwk);

        System.out.println("JWKS (public):");
        System.out.println(jwks.toJSONObject().toString());

        // The private key stays on your server, never expose it
        System.out.println("\nPrivate JWK (keep secret):");
        System.out.println(jwk.toJSONObject().toString());
    }
}
  

This produces a JWKS JSON object you can serve at /.well-known/jwks.json. In a Spring Boot application, you would typically serve this from a controller or, more commonly, let your identity provider handle it.

Serving a JWKS endpoint in Spring Boot

If you are managing your own keys and need to expose a JWKS endpoint:

  
import com.nimbusds.jose.jwk.JWKSet;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class JwksController {

    private final JWKSet jwkSet;

    public JwksController(JWKSet jwkSet) {
        this.jwkSet = jwkSet;
    }

    @GetMapping("/.well-known/jwks.json")
    public String jwks() {
        // toPublicJWKSet() strips private keys automatically
        return jwkSet.toPublicJWKSet().toJSONObject().toString();
    }
}
  

Creating a JWT in Java

Once you have your RSA keys, you can create and sign a token using the private key:

  
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.util.Date;
import java.util.List;

public class JwtCreator {
    public static String createToken(RSAKey rsaKey) throws Exception {
        // Create the signer using the private key
        JWSSigner signer = new RSASSASigner(rsaKey);

        // Build the claims
        JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
                .subject("user_123")
                .issuer("https://your-app.example.com")
                .audience("your-api")
                .issueTime(new Date())
                .expirationTime(new Date(System.currentTimeMillis() + 15 * 60 * 1000)) // 15 minutes
                .claim("email", "hgranger@hogwarts.example")
                .claim("roles", List.of("admin", "editor"))
                .claim("department", "engineering")
                .build();

        // Create the signed JWT
        SignedJWT signedJWT = new SignedJWT(
                new JWSHeader.Builder(JWSAlgorithm.RS256)
                        .keyID(rsaKey.getKeyID())
                        .build(),
                claimsSet
        );

        // Sign it
        signedJWT.sign(signer);

        // Serialize to the compact form: header.payload.signature
        return signedJWT.serialize();
    }
}
  

This snippet does the following:

  1. Creates an RSASSASigner from the private key.
  2. Builds a JWTClaimsSet with standard claims (sub, iss, aud, iat, exp) and custom claims (email, roles, department).
  3. Constructs a SignedJWT with an RS256 header that includes the kid.
  4. Signs the token and serializes it to the compact string format.

Notice that the kid (key ID) is included in the header. This is important for key rotation, which we will cover later.

Sending the token as a Bearer token

Once the client has the JWT, it sends it in the Authorization header as a Bearer token. The Bearer prefix tells the API that whoever bears this token can use it:

  
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

String jwt = "eyJhbGciOiJSUzI1NiIs...";

HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.example.com/protected"))
        .header("Authorization", "Bearer " + jwt)
        .GET()
        .build();

HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
  

On the server side, you extract the token from this header before validating it. In Spring Boot, the framework handles this automatically if you configure the OAuth2 resource server, but understanding the mechanics matters for debugging and for applications that do not use Spring.

Adding standard and custom claims

JWT claims fall into two categories.

Standard claims

Common registered claims include:

  • sub (subject): what the token is about, typically the user's unique identifier.
  • iss (issuer): who issued the token.
  • aud (audience): who the token is intended for.
  • exp (expiration time): when the token expires, in seconds since the Unix epoch.
  • iat (issued at): when the token was issued.
  • nbf (not before): when the token becomes valid.
  • jti (JWT ID): a unique identifier for the token, useful for revocation.

With Nimbus, you set these using the JWTClaimsSet.Builder:

  
new JWTClaimsSet.Builder()
        .subject("user_123")
        .issuer("https://your-app.example.com")
        .audience("your-api")
        .expirationTime(new Date(System.currentTimeMillis() + 900_000))
        .issueTime(new Date())
        .notBeforeTime(new Date())
        .jwtID(UUID.randomUUID().toString())
        .build();
  

Custom claims

Custom claims are application-specific data added via the .claim() method:

  
new JWTClaimsSet.Builder()
        .subject("user_123")
        .claim("email", "hgranger@hogwarts.example")
        .claim("roles", List.of("admin", "editor"))
        .claim("department", "engineering")
        .claim("email_verified", true)
        .build();
  

Be careful not to include sensitive information. JWT payloads are encoded, not encrypted. Anyone who intercepts the token can read the claims.

Decoding a JWT

Decoding a JWT without verifying it can be useful for debugging and logging, but it should never be used for authorization decisions.

  
import com.nimbusds.jwt.SignedJWT;

String token = "eyJhbGciOiJSUzI1NiIs...";

SignedJWT signedJWT = SignedJWT.parse(token);

// Read the header
System.out.println("Algorithm: " + signedJWT.getHeader().getAlgorithm());
System.out.println("Key ID: " + signedJWT.getHeader().getKeyID());

// Read the payload (claims) without verifying the signature
JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
System.out.println("Subject: " + claims.getSubject());
System.out.println("Issuer: " + claims.getIssuer());
System.out.println("Expiration: " + claims.getExpirationTime());
System.out.println("Email: " + claims.getStringClaim("email"));
System.out.println("Roles: " + claims.getStringListClaim("roles"));
  

This parses the token and gives you access to the header and claims. The signature has not been checked at this point. Do not trust any of these values until you verify the token.

About the kid claim

The kid (key ID) appears in the JWT header, not the payload:

  
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "abc123"
}
  

It tells your application which public key (from a set of keys) should be used to verify the signature. This is essential when your authentication provider uses key rotation, publishing multiple public keys at a JWKS endpoint and including kid in the JWT header to indicate which key was used to sign it.

When your app receives a JWT, it extracts the kid from the header, looks up the matching public key in the JWKS, and uses that key to verify the signature. Nimbus handles this automatically through its JWKSource and JWSKeySelector classes, which we will use in the next section.

Verifying a JWT

Verification ensures three things: the signature is valid, the token has not expired, and the claims match your expectations. This is where Nimbus shines compared to simpler libraries, because it integrates JWKS fetching, key selection, and claim validation into a single pipeline.

Verifying with a JWKS endpoint

This is the recommended approach for production. Your identity provider publishes its public keys at a JWKS URL, and Nimbus fetches and caches them automatically:

  
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSKeySelector;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import java.net.URL;

public class JwtVerifier {

    private final ConfigurableJWTProcessor<SecurityContext> jwtProcessor;

    public JwtVerifier(String jwksUrl) throws Exception {
        // Build a JWKS source that fetches and caches keys
        JWKSource<SecurityContext> keySource = JWKSourceBuilder
                .create(new URL(jwksUrl))
                .retrying(true)
                .build();

        // Only accept RS256
        JWSKeySelector<SecurityContext> keySelector =
                new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keySource);

        // Configure the processor
        jwtProcessor = new DefaultJWTProcessor<>();
        jwtProcessor.setJWSKeySelector(keySelector);
    }

    public JWTClaimsSet verify(String token) throws Exception {
        return jwtProcessor.process(token, null);
    }
}
  

Usage:

  
JwtVerifier verifier = new JwtVerifier("https://api.workos.com/sso/jwks/your-client_id");

try {
    JWTClaimsSet claims = verifier.verify(token);
    System.out.println("Subject: " + claims.getSubject());
    System.out.println("Email: " + claims.getStringClaim("email"));
} catch (Exception e) {
    System.out.println("Token verification failed: " + e.getMessage());
}
  

The JWKSourceBuilder handles several important concerns automatically:

  • Key caching. Keys are fetched once and cached. Subsequent verifications use the cached keys without making network calls.
  • Key rotation. If a token arrives with a kid that is not in the cache, the source re-fetches the JWKS from the endpoint. This handles key rotation transparently.
  • Retry logic. The .retrying(true) option adds resilience for transient network failures.
  • Thread safety. The JWKSource and DefaultJWTProcessor are thread-safe. You can share a single instance across your application.

The JWSVerificationKeySelector with JWSAlgorithm.RS256 ensures that only RS256-signed tokens are accepted. If an attacker sends a token signed with a different algorithm (like HS256 or none), it will be rejected. This is critical for preventing algorithm confusion attacks.

Verifying with a local public key

If you are not using a JWKS endpoint and have the public key available locally:

  
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jwt.SignedJWT;
import java.security.interfaces.RSAPublicKey;

SignedJWT signedJWT = SignedJWT.parse(token);

// Verify the signing algorithm is what you expect
if (!signedJWT.getHeader().getAlgorithm().equals(JWSAlgorithm.RS256)) {
    throw new SecurityException("Unexpected signing algorithm: " + signedJWT.getHeader().getAlgorithm());
}

RSASSAVerifier verifier = new RSASSAVerifier(publicKey);
boolean isValid = signedJWT.verify(verifier);

if (!isValid) {
    throw new SecurityException("Invalid JWT signature");
}

// Now validate the claims
JWTClaimsSet claims = signedJWT.getJWTClaimsSet();

if (claims.getExpirationTime().before(new Date())) {
    throw new SecurityException("Token has expired");
}

if (!"https://your-app.example.com".equals(claims.getIssuer())) {
    throw new SecurityException("Invalid issuer");
}

if (!claims.getAudience().contains("your-api")) {
    throw new SecurityException("Invalid audience");
}
  

Always check the algorithm in the header before verifying. If you skip this step, an attacker could send a token signed with HS256 using the public key as the shared secret, and your verifier might accept it. This is the algorithm confusion attack, and it is one of the most well-known JWT vulnerabilities.

Adding claims validation

When using the DefaultJWTProcessor, you can add a claims verifier to validate standard and custom claims automatically:

  
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import java.util.Set;

// Require specific claim values and the presence of certain claims
jwtProcessor.setJWTClaimsSetVerifier(
    new DefaultJWTClaimsVerifier<>(
        new JWTClaimsSet.Builder()
                .issuer("https://your-app.example.com")
                .audience("your-api")
                .build(),
        // Required claims that must be present (values checked separately)
        Set.of("sub", "exp", "iat")
    )
);
  

This verifier checks that the iss and aud match the expected values, that sub, exp, and iat are present, and that the token has not expired. If any check fails, the process() call throws an exception.

Handling custom claims

Once verified, custom claims are available through typed accessor methods on JWTClaimsSet:

  
JWTClaimsSet claims = verifier.verify(token);

// Typed access to custom claims
String email = claims.getStringClaim("email");
List<String> roles = claims.getStringListClaim("roles");
Boolean emailVerified = claims.getBooleanClaim("email_verified");
String department = claims.getStringClaim("department");

// Use roles for authorization
if (roles == null || !roles.contains("admin")) {
    throw new SecurityException("Insufficient permissions");
}
  

Nimbus provides typed methods for common claim types: getStringClaim(), getStringListClaim(), getBooleanClaim(), getIntegerClaim(), getLongClaim(), getDateClaim(), and getJSONObjectClaim(). These return null if the claim is not present, so check for null before using the value.

For more complex claim structures, use getJSONObjectClaim() which returns a Map<String, Object>:

  
Map<String, Object> featureFlags = claims.getJSONObjectClaim("feature_flags");
if (featureFlags != null) {
    Boolean betaAccess = (Boolean) featureFlags.get("beta_access");
}
  

Integrating with Spring Security

Most Java applications in production use Spring Boot, and Spring Security provides first-class JWT support through its OAuth2 resource server module. Under the hood, it uses Nimbus JOSE + JWT.

Dependency

Add the Spring Security OAuth2 resource server starter:

  
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
  

Configuration

Point Spring to your JWKS endpoint in application.yml:

  
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://api.workos.com/sso/jwks/your-client_id
          issuer-uri: https://your-app.example.com
  

Spring Security will automatically fetch the JWKS, cache the keys, verify signatures on incoming requests, and validate standard claims like exp and iss.

Security filter chain

Configure which endpoints require authentication:

  
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .requestMatchers("/admin/**").hasAuthority("SCOPE_admin")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            );
        return http.build();
    }
}
  

Mapping custom claims to authorities

By default, Spring Security maps the scope or scp claim to granted authorities. If your JWTs use a custom roles claim instead, you need a custom converter:

  
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

public class CustomJwtConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        List<String> roles = jwt.getClaimAsStringList("roles");

        Collection<SimpleGrantedAuthority> authorities = roles != null
                ? roles.stream()
                    .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                    .collect(Collectors.toList())
                : List.of();

        return new JwtAuthenticationToken(jwt, authorities, jwt.getSubject());
    }
}
  

Wire it into the configuration:

  
@Bean
public Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter() {
    return new CustomJwtConverter();
}
  

Accessing the authenticated user in controllers

Once Spring Security verifies the JWT, you can access the claims in your controllers:

  
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DashboardController {

    @GetMapping("/dashboard")
    public Map<String, Object> dashboard(@AuthenticationPrincipal Jwt jwt) {
        return Map.of(
                "userId", jwt.getSubject(),
                "email", jwt.getClaimAsString("email"),
                "roles", jwt.getClaimAsStringList("roles")
        );
    }
}
  

The @AuthenticationPrincipal Jwt jwt parameter is injected automatically by Spring Security after the token has been verified. This is the idiomatic way to access JWT claims in Spring Boot applications.

Building a JWT filter without Spring Security

If you are not using Spring Boot, or if you need to handle JWT verification in a plain servlet application or a framework like Micronaut or Quarkus, you can build a servlet filter that uses the Nimbus JwtVerifier class from earlier:

  
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

public class JwtAuthFilter implements Filter {

    private final JwtVerifier jwtVerifier;

    public JwtAuthFilter(String jwksUrl) throws Exception {
        this.jwtVerifier = new JwtVerifier(jwksUrl);
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest httpReq = (HttpServletRequest) req;
        HttpServletResponse httpRes = (HttpServletResponse) res;

        String authHeader = httpReq.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            httpRes.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            httpRes.getWriter().write("{\"error\": \"Missing or malformed token\"}");
            return;
        }

        String token = authHeader.substring("Bearer ".length());

        try {
            JWTClaimsSet claims = jwtVerifier.verify(token);
            // Store claims in the request for downstream handlers
            httpReq.setAttribute("jwt_claims", claims);
            chain.doFilter(req, res);
        } catch (Exception e) {
            httpRes.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            httpRes.getWriter().write("{\"error\": \"Invalid or expired token\"}");
        }
    }
}
  

Register the filter in your servlet configuration to protect specific URL patterns. This approach works with any Java web framework that supports the Servlet API.

JWT best practices (Java edition)

JWTs are simple in structure, but security lives in the details you enforce. Here are the practices that matter most in production Java applications.

  • Always verify the signature. Do not trust a token just because it parses cleanly. Only use claims for authorization decisions after verification succeeds. With Nimbus, always use DefaultJWTProcessor.process() or RSASSAVerifier.verify(), never rely on SignedJWT.parse() alone.
  • Enforce the expected algorithm. Configure your JWSKeySelector to accept only the algorithm you expect (for example, RS256), and reject anything else. "Accept whatever the header says" is how algorithm confusion attacks happen.
  • Validate critical standard claims. At minimum, validate exp (expiration), iss (issuer), and aud (audience). Use nbf (not before) and iat (issued at) where appropriate. If you deal with clock drift between systems, allow a small amount of leeway (30 to 60 seconds) rather than loosening validation entirely.
  • Use a JWKS endpoint when possible. If your tokens are issued by an identity provider, verify against their JWKS so you can automatically select the right public key by kid. Nimbus's JWKSourceBuilder handles caching and refresh. Do not fetch the JWKS on every request.
  • Plan for key rotation. Rotation is not a "later" problem. If you manage your own keys, publish new keys before you start signing with them, keep old keys available at your JWKS endpoint until tokens signed with them expire, and use kid to distinguish active from retired keys. Nimbus's JWKSource re-fetches the JWKS automatically when it encounters an unknown kid, which makes rotation seamless for verifiers.
  • Enforce Bearer token format. Require tokens in the Authorization header in this exact format: Authorization: Bearer <jwt>. Treat tokens in query parameters as a problem, because they leak into logs, browser history, and referrer headers.
  • Keep access tokens short-lived. Short exp values (5 to 15 minutes) reduce the blast radius of a leaked token. If you need long sessions, use refresh tokens and rotate them.
  • Handle verification errors explicitly. Verification can fail for many reasons: expired token, bad signature, wrong iss, wrong aud, unknown kid. Map these to clean HTTP responses. Return 401 Unauthorized for missing, invalid, or expired tokens. Return 403 Forbidden for valid tokens that lack required permissions.
  • Use HTTPS everywhere. JWTs are bearer credentials. If someone can intercept the request, they can replay the token.
  • Centralize JWT logic. Put verification in a servlet filter, Spring Security configuration, or framework interceptor so every protected endpoint enforces the same checks. Do not scatter partial verification logic across individual controllers.
  • Log failures carefully. Log high-level context (like kid, iss, and the reason verification failed) and never log full tokens or entire payloads. Tokens are sensitive credentials.
  • Be aware of thread safety. Nimbus's DefaultJWTProcessor and JWKSource are thread-safe. You should create a single instance and share it across your application rather than creating new instances per request. This is important in Java, where web frameworks handle requests across multiple threads.
  • Test with bad tokens. Make sure your test suite covers expired tokens, tokens with wrong iss or aud, tokens signed with the wrong key, tokens with tampered payloads, missing required claims, and wrong algorithm or none edge cases.

Let WorkOS handle the heavy lifting

While handling JWTs with libraries like Nimbus is often necessary at the API layer, it is worth stepping back and looking at the bigger picture: how those tokens are issued in the first place.

If you are building authentication flows, especially ones that involve Single Sign-On (SSO), SCIM provisioning, or multi-tenant identity, there is a lot more to solve than signing and verifying tokens. You need to support different identity providers, manage users and directories, rotate keys safely, and issue tokens that downstream services can trust.

WorkOS provides a modern API for enterprise-ready authentication features, letting you integrate SSO (SAML, OIDC, and more), manage users and directories, and issue secure tokens without building and maintaining a full auth stack from scratch. WorkOS has a Java SDK (Kotlin-based, compatible with any JVM application) that handles the OAuth flow, token exchange, and user management. It is especially useful if you need to support enterprise customers or want to offer a "Login with your company" experience. And it is free for up to 1,000,000 monthly active users.

If you are tired of stitching together SSO flows or wrestling with SAML metadata by hand, it is worth checking out WorkOS.

Sign up for WorkOS today.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.