JSON Web Token (JWT) Explained With Java jjwt Examples

Learn how a JSON Web Token (JWT) works, create and validate one with jjwt 0.13 and nimbus-jose-jwt, and avoid the storage, key and expiry mistakes.

Structure of a JWT with Base64URL-encoded header and payload and an HMAC-SHA256 signature over both

A JSON Web Token (JWT) is a compact, URL-safe string that carries a set of claims, such as a user ID and roles, together with a signature that lets the receiver check that nobody changed them. The format is defined in RFC 7519, and every major language has libraries for it.

We use JWTs as access tokens for REST APIs, as ID tokens in OpenID Connect logins, and for calls between services, because any server with the right key can check a token without a shared session store.

The following example creates and verifies a JWT with the jjwt library 0.13 on Java 25.

SecretKey key = Jwts.SIG.HS256.key().build();                 // random 256-bit HMAC key
String token = Jwts.builder().subject("lokesh").claim("role", "member").signWith(key).compact();
String header = token.substring(0, token.indexOf('.'));       // "eyJhbGciOiJIUzI1NiJ9"
Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload();
String user = claims.getSubject();                            // "lokesh"
String role = claims.get("role", String.class);               // "member"

Notice that the parser returns the claims only after it has checked the signature with the same key. We look inside a token, compare JWTs with server sessions, build a validating parser, sign with RSA keys, and go through the mistakes that make JWT setups insecure.

1. What Is Inside a JWT

A signed JWT has three parts separated by dots, namely the header, the payload and the signature. The header and the payload are JSON objects encoded with Base64URL, a Base64 variant without the characters that have a meaning in URLs. Base64URL is an encoding, not encryption, so anyone who has the token can read them.

Structure of a JWT with Base64URL-encoded header and payload and an HMAC-SHA256 signature over both
Header and payload are only encoded; the signature is what makes the token tamper-proof

We can decode the payload of any token with the JDK alone. The following token has a subject and a role claim and no expiry, so its parts are the same on every run.

SecretKey key = Jwts.SIG.HS256.key().build();
String sample = Jwts.builder().subject("lokesh").claim("role", "member").signWith(key).compact();
String[] parts = sample.split("\\.");
int partCount = parts.length;                                                                  // 3
String headerJson = new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8);   // {"alg":"HS256"}
String payloadJson = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);  // {"sub":"lokesh","role":"member"}

The header names the signing algorithm in alg, here HMAC with SHA-256 (HS256). The signature is the HMAC of the encoded header, a dot and the encoded payload, computed with the secret key. If someone changes one character of the payload, the signature no longer matches.

1.1. Registered Claims

The payload holds claims, which are name-value pairs. RFC 7519 registers seven short names that every library understands, and we add our own claims, such as role, next to them.

ClaimNameMeaningjjwt builder method
issIssuerWho created the token, e.g. the login server URLissuer()
subSubjectWhom the token is about, such as the user IDsubject()
audAudienceWhich API the token is meant foraudience().add()
expExpiration timeAfter this time the token is rejectedexpiration()
nbfNot beforeBefore this time the token is rejectednotBefore()
iatIssued atWhen the token was createdissuedAt()
jtiJWT IDUnique token ID, used for deny listsid()

Times are seconds since 1970 (Unix time) in the JSON. Because the payload is readable, it never contains passwords, card numbers or other secrets.

2. JWT vs Server-Side Sessions

With a classic session, the server stores the logged-in user in memory or in a database and gives the browser a random session ID in a cookie, such as JSESSIONID. Every request needs a lookup in that store. With a JWT, the token itself carries the user and the roles, and the server only checks the signature and the expiry.

Say a library app runs five instances of its book-loan API behind a load balancer. With sessions, all five need a shared session store such as Redis, or sticky sessions. With JWTs from a login server, each instance verifies the token on its own with the login server’s public key.

Server sessionJWT
Where the user data livesServer storeInside the token
What the client sendsRandom session ID (cookie)Signed token (header or cookie)
Logout or revokeDelete the session, effective at onceToken stays valid until exp unless we keep a deny list
Scaling across serversShared store or sticky sessionsAny server with the key
Size per requestAbout 32 bytesSeveral hundred bytes
Good fitServer-rendered web appsAPIs, mobile apps, service-to-service calls

A JWT is not a better session cookie but a different trade-off. For a single server-rendered app, a session is simpler and logs users out instantly. JWTs pay off when several APIs must trust the same login without calling it on every request.

3. Creating and Verifying a JWT With jjwt

The jjwt library splits into an API JAR for compile time and an implementation plus a JSON library for runtime. The 0.12 release renamed the builder methods (subject() instead of setSubject()) and the parser methods, so code from older tutorials does not compile against it.

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.13.0</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.13.0</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.13.0</version>
    <scope>runtime</scope>
</dependency>

A login service creates the token after it has checked the password. The token names the issuer and the audience and expires after 15 minutes, which limits the damage if it leaks.

static String issueToken(SecretKey key, String userId, Instant now) {
    return Jwts.builder()
            .issuer("https://login.example.com")
            .subject(userId)
            .audience().add("loan-api").and()
            .claim("role", "member")
            .issuedAt(Date.from(now))
            .expiration(Date.from(now.plus(15, ChronoUnit.MINUTES)))
            .signWith(key)
            .compact();
}

The API side builds one parser at startup and reuses it, because a built JwtParser is immutable and thread-safe. The require*() methods check the issuer and the audience, so the parser rejects tokens from another issuer or for another API.

static JwtParser loanApiParser(SecretKey key) {
    return Jwts.parser()
            .verifyWith(key)
            .requireIssuer("https://login.example.com")
            .requireAudience("loan-api")
            .clockSkewSeconds(60)                 // tolerate small clock differences
            .build();
}
SecretKey key = Jwts.SIG.HS256.key().build();
JwtParser parser = loanApiParser(key);
String token = issueToken(key, "lokesh", Instant.now());
Claims claims = parser.parseSignedClaims(token).getPayload();
String user = claims.getSubject();                          // "lokesh"
boolean forLoanApi = claims.getAudience().contains("loan-api");   // true

3.1. What Happens With a Bad Token

Every failure throws a subclass of JwtException, so an API filter catches that one type and returns HTTP 401. The specific types help in logs and tests.

SecretKey key = Jwts.SIG.HS256.key().build();
JwtParser parser = loanApiParser(key);
String expired = issueToken(key, "lokesh", Instant.now().minus(1, ChronoUnit.HOURS));
String[] parts = issueToken(key, "lokesh", Instant.now()).split("\\.");
String forged = parts[0] + "." + Base64.getUrlEncoder().withoutPadding().encodeToString("{\"sub\":\"admin\"}".getBytes()) + "." + parts[2];
String otherKey = issueToken(Jwts.SIG.HS256.key().build(), "lokesh", Instant.now());

Claims c1 = parser.parseSignedClaims(expired).getPayload();    // ExpiredJwtException: JWT expired ...
Claims c2 = parser.parseSignedClaims(forged).getPayload();     // SignatureException: JWT signature does not match
Claims c3 = parser.parseSignedClaims(otherKey).getPayload();   // SignatureException: JWT signature does not match

The forged token changes the subject to admin but keeps the old signature, and the parser rejects it. A token signed with a different key fails the same way, which is the protection the signature gives us.

4. Signing With RSA Keys and JWKS

With HS256, the same secret signs and verifies, so every API that checks tokens could also create them. RS256 and ES256 use a key pair instead. The login server keeps the private key and signs, and the APIs get only the public key, which cannot create tokens.

KeyPair pair = Jwts.SIG.RS256.keyPair().build();             // 2048-bit RSA key pair
String token = Jwts.builder().subject("lokesh").signWith(pair.getPrivate()).compact();
String user = Jwts.parser().verifyWith(pair.getPublic()).build()
        .parseSignedClaims(token).getPayload().getSubject();
String alg = Jwts.parser().verifyWith(pair.getPublic()).build()
        .parseSignedClaims(token).getHeader().getAlgorithm();
String checkedUser = user;                                     // "lokesh"
String checkedAlg = alg;                                       // "RS256"

Login servers such as Keycloak, Auth0 or Spring Authorization Server publish their public keys as a JSON Web Key Set (JWKS) at a URL. The APIs download the set, pick the key by the kid header and cache it, so the login server can rotate keys without redeploying the APIs.

The nimbus-jose-jwt library, which Spring Security uses inside its resource server support, works with JWK objects as Java classes. The same RS256 flow with nimbus-jose-jwt 10 builds the header and the claims as separate objects.

RSAKey rsaJwk = new RSAKeyGenerator(2048).keyID("key-1").generate();
SignedJWT jwt = new SignedJWT(
        new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("key-1").build(),
        new JWTClaimsSet.Builder().subject("lokesh").issuer("https://login.example.com").build());
jwt.sign(new RSASSASigner(rsaJwk));
String serialized = jwt.serialize();
boolean valid = SignedJWT.parse(serialized).verify(new RSASSAVerifier(rsaJwk.toPublicJWK()));   // true
String publicJwk = rsaJwk.toPublicJWK().toJSONString();      // {"kty":"RSA","e":"AQAB","kid":"key-1","n":"..."}

Both libraries are maintained and run on Java 25. The jjwt API is shorter for creating and parsing tokens, whereas nimbus-jose-jwt covers more of the JOSE specs, such as JWKS handling and encrypted JWTs (JWE).

5. Common JWT Mistakes

Most JWT breaches come from how tokens are created, stored and checked, not from the signature algorithms. The library handles some of these mistakes for us, and the rest are design decisions.

5.1. Weak HMAC Secrets

An HS256 key must have at least 256 bits of randomness, because a short secret can be guessed offline from one captured token. The jjwt library refuses short keys, so the classic tutorial secret fails at once.

SecretKey weak = Keys.hmacShaKeyFor("mySecretKey".getBytes(StandardCharsets.UTF_8));   // WeakKeyException: The specified key byte array is 88 bits

We generate the key with Jwts.SIG.HS256.key().build(), store its Base64 form in a secret manager, and load it with Keys.hmacShaKeyFor(Decoders.BASE64.decode(value)).

5.2. Unsigned Tokens and Algorithm Confusion

A JWT header can say “alg”:”none”, which means no signature at all. Some old libraries accepted such tokens, and others let the token header choose the algorithm, so an attacker could switch from RS256 to HS256 and sign with the public key as an HMAC secret. Our parser decides the algorithm through the key type, and jjwt rejects unsecured tokens by default.

String unsigned = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJhZG1pbiJ9.";
JwtParser parser = Jwts.parser().verifyWith(Jwts.SIG.HS256.key().build()).build();
Claims none = parser.parseSignedClaims(unsigned).getPayload();   // UnsupportedJwtException: Unsecured JWSs ... are disallowed by default

5.3. Storing Tokens in localStorage

Any script on the page can read localStorage, so one cross-site scripting (XSS) bug leaks every token. For browser apps, the safer pattern is a cookie with the HttpOnly, Secure and SameSite flags, which scripts cannot read, or a backend-for-frontend that keeps tokens on the server. Cookies bring back cross-site request forgery (CSRF), so the API needs CSRF protection or SameSite=Strict.

5.4. Long Lifetimes Without Revocation

A signed token stays valid until exp, even after logout or a password change. We keep access tokens short, for example 5 to 15 minutes, and issue a refresh token that the login server can revoke. For instant revocation, the API checks the jti claim against a small deny list until the token expires.

6. JWT With OAuth 2 and OpenID Connect

In most production systems, we do not write the token code ourselves. An OAuth 2 authorization server issues the JWTs, and our APIs act as resource servers that only validate them. OpenID Connect (OIDC) adds an ID token, also a JWT, that tells the client app who logged in.

  • The access token goes to APIs in the Authorization: Bearer header, and its audience is the API.
  • The ID token stays in the client app, and its audience is the client ID. APIs must not accept ID tokens as access tokens.
  • Browser and mobile apps use the authorization code flow with PKCE. The implicit flow, which returned tokens in the URL, is discouraged by the OAuth 2.0 Security Best Current Practice and dropped from the OAuth 2.1 draft.

In a Spring Boot application, one property turns the app into a resource server that downloads the JWKS, checks the signature and validates iss and exp on every request. The OAuth2 login with Spring Boot article covers the client side of the same flow.

spring.security.oauth2.resourceserver.jwt.issuer-uri=https://login.example.com/realms/library

7. JSON Web Token FAQs

The first JWT login in an app raises the same doubts about storage, encryption and logout.

7.1. Is a JWT encrypted?

No. A signed JWT (JWS) is only encoded, and anyone can read its payload, as we did in section 1. An encrypted JWT (JWE) exists, and both jjwt and nimbus-jose-jwt support it, but most access tokens are signed only and rely on HTTPS for confidentiality.

7.2. What is the difference between a JWT and a cookie?

A cookie is a way to send data with every browser request, and a JWT is a token format. They are not alternatives, because a JWT can travel in a cookie. The real choice is between a server session, where a cookie holds a random ID, and a self-contained token, as compared in section 2.

7.3. What is a refresh token?

A refresh token is a long-lived credential that the client sends only to the authorization server to get a new access token. It lets us keep access tokens short-lived without asking the user to log in again, and the server can revoke it. Refresh tokens should be rotated, so each use returns a new one and an old one cannot be replayed.

7.4. Can we invalidate a JWT before it expires?

Not by itself. The API accepts any token with a valid signature until exp. We either keep the lifetime short, or check the jti claim against a deny list in a fast store such as Redis, which brings back a small part of the server state that JWTs avoid.

7.5. Should I use HS256 or RS256?

Use HS256 when one service both creates and verifies tokens. Use RS256 or ES256 when other services verify the tokens, because they get only the public key and cannot mint tokens.

8. Conclusion

A JWT is a signed, Base64URL-encoded set of claims that any server with the key can verify without a session store. The signature protects the claims from changes, but it does not hide them, so the payload holds identifiers and roles, never secrets.

With jjwt 0.13, we create tokens with Jwts.builder() and validate them with a reusable parser that requires the issuer and the audience. For real systems, an OAuth 2 authorization server signs tokens with a private key, the APIs validate them through a JWKS URL, and short lifetimes plus refresh tokens make up for the missing logout.

9. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.