JWT — JSON Web Tokens

A signed, self-contained claim — not an encrypted one — and the specific vulnerabilities and storage decisions that follow directly from that fact

← Back to Index

What is a JWT — and Why Does it Exist?

A JWT (JSON Web Token) is a signed, self-contained claim about an identity — the token itself carries enough information (who the subject is, what roles they have, when it expires) that a server can verify it's authentic and act on it without looking anything up. This is the direct answer to the scaling problem described on the previous page: a session requires every instance to query a shared store on every request; a JWT only requires verifying a cryptographic signature, which needs no network call and no shared infrastructure at all. That's exactly why JWTs are the default choice for authenticating calls between microservices — an inventory service receiving a request from an order service can verify the caller's claims using only a public key it already has, with zero coupling to whatever system originally issued the token.

The trade-off, covered in full at the end of this page, is the one already previewed on the Session Management page: no shared store means no instant server-side revocation. Everything else about how JWTs are built, signed, and stored follows from a single fact worth stating plainly up front: a JWT is signed, not encrypted. Its payload is base64url-encoded — trivially readable by anyone who intercepts it — and the signature only proves the claims weren't tampered with after issuance, not that they're secret.

// BEFORE — every request pays for a network round-trip to a shared session store
// just to answer "who is this and what are they allowed to do?"
SessionData session = redisTemplate.opsForValue().get("session:" + sessionId);
if (session == null || session.isExpired()) {
    throw new BadCredentialsException("Invalid or expired session");
}
// Every service in the chain (order-service, inventory-service, shipping-service)
// needs network access to the SAME Redis instance to answer this question.

// AFTER — verification is a local, offline cryptographic operation
Claims claims = Jwts.parser()
    .verifyWith(publicKey)          // only needs the ISSUER's public key — never a shared secret store
    .build()
    .parseSignedClaims(token)
    .getPayload();
// No network call. No shared store. Any service holding the public key can
// verify this independently — that's the entire point.

Anatomy of a JWT — Three Base64url Segments, Not Encryption

/*
 * A JWT is three base64url-encoded segments joined by dots:
 *
 *   eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MjIiLCJyb2xlIjoiQ1VTVE9NRVIi...}.dBjftJeZ4CVP-mB92K...
 *   └──────────── header ────────────┘ └──────────── payload ────────────┘ └── signature ──┘
 *
 * HEADER (decoded):     {"alg": "RS256", "typ": "JWT"}
 * PAYLOAD (decoded):    {"sub": "422", "role": "CUSTOMER", "iat": 1751000000, "exp": 1751003600}
 * SIGNATURE:            RSA-SHA256(base64url(header) + "." + base64url(payload), privateKey)
 *
 * base64url is an ENCODING, not encryption. Anyone can decode the header and
 * payload with zero cryptographic knowledge — paste any JWT into a decoder and
 * read every claim in plaintext. The signature's only job is proving the
 * header+payload weren't modified after the issuer signed them.
 */
Never put anything in a JWT payload you wouldn't put in a URL query parameter

Because the payload is trivially decodable, a JWT must never carry a password, a card number, a full address, or any other value that needs confidentiality. Standard claims (sub, iat, exp, iss) plus a role or a small set of authorization-relevant identifiers is the appropriate scope of what belongs in a token.

Standard Claims

ClaimMeaning
subSubject — the principal this token is about (typically a user or account ID)
issIssuer — which authorization server or service issued this token
audAudience — which service(s) this token is intended for; a resource server should reject tokens not meant for it
iatIssued-at timestamp
expExpiration timestamp — a verifier must reject any token past this instant
jtiJWT ID — a unique identifier for this specific token, useful for tracking or one-time-use revocation lists

Signing Algorithms — Symmetric (HMAC) vs Asymmetric (RSA/EC)

FamilyExample algorithmsKey modelFits
HMAC HS256, HS384, HS512 One shared secret used for both signing and verifying A single service that issues and verifies its own tokens
RSA / EC RS256, RS384, ES256 Private key signs, public key verifies — never the same key Microservices: any number of services verify tokens using only a distributed public key, and only the authorization server holds the private key
@Service
public class JwtService {

    private final PrivateKey signingKey;      // held ONLY by the issuing service
    private final PublicKey verificationKey;

    public JwtService(PrivateKey signingKey, PublicKey verificationKey) {
        this.signingKey = signingKey;
        this.verificationKey = verificationKey;
    }

    public String generateToken(Authentication auth) {
        Instant now = Instant.now();
        return Jwts.builder()
            .subject(auth.getName())
            .claim("role", extractRole(auth))
            .issuer("https://auth.shop.example.com")
            .audience().add("order-service").and()
            .issuedAt(Date.from(now))
            .expiration(Date.from(now.plus(Duration.ofMinutes(15))))   // short-lived access token
            .signWith(signingKey, Jwts.SIG.RS256)
            .compact();
    }

    public Claims parseAndValidate(String token) {
        return Jwts.parser()
            .verifyWith(verificationKey)
            .requireIssuer("https://auth.shop.example.com")
            .requireAudience("order-service")
            .build()
            .parseSignedClaims(token)
            .getPayload();   // throws ExpiredJwtException / SignatureException on any failure — never swallow these
    }
}

Two Vulnerabilities That Come Directly From How JWTs Are Structured

1. The "alg": "none" Trap

/*
 * The JWT spec permits an "none" algorithm for genuinely unsecured use cases.
 * Some early JWT libraries honored whatever "alg" the ATTACKER put in the
 * header — including "none" — and skipped signature verification entirely.
 *
 * Attack: take a legitimate token, change the header to {"alg":"none"},
 * change any claim (e.g. "role":"CUSTOMER" → "role":"ADMIN"), drop the
 * signature segment entirely. A vulnerable verifier accepts it as valid.
 */

// THE FIX: never let the token's own header dictate which algorithm verifies
// it — pin the expected algorithm explicitly at verification time
Claims claims = Jwts.parser()
    .verifyWith(verificationKey)
    // Modern JJWT (and most current libraries) require you to configure the
    // accepted algorithm(s) up front via the key type — an RSA PublicKey here
    // means ONLY RS256/RS384/RS512 signatures are ever accepted, "none" and
    // HS256 are rejected outright rather than silently trusted from the header.
    .build()
    .parseSignedClaims(token)
    .getPayload();

2. Algorithm Confusion — RS256 Downgraded to HS256

/*
 * If a verifier is written to accept "whatever alg the token header claims"
 * rather than a fixed expected algorithm, a second attack becomes possible:
 *
 * 1. The legitimate system signs with RS256 (private key signs, public key
 *    verifies) and publishes its RSA PUBLIC key openly, as intended.
 * 2. An attacker crafts a forged token with header {"alg":"HS256"} and signs
 *    it using an HMAC secret equal to... the RSA public key's bytes.
 * 3. A verifier that naively does "look at alg in the header, then verify
 *    using whatever key I have configured, treating it as that algorithm's
 *    key type" ends up computing HMAC-SHA256(header.payload, publicKeyBytes)
 *    — and since the public key is, by definition, public, the attacker CAN
 *    compute that exact same HMAC and produce a signature that validates.
 */

// THE FIX is the same discipline as above: the verifier must hardcode which
// algorithm family it expects for a given key, never derive it from the
// token's own header. This is precisely why modern JWT libraries tie key
// TYPE to algorithm — an RSA PublicKey object simply cannot be handed to an
// HMAC verification path in a well-designed API.

Client-Side Storage — localStorage vs an HttpOnly Cookie

This decision gets debated constantly, and the honest answer is that both options trade one risk for a different one — there is no zero-risk choice, only an explicit one.

StorageVulnerable toNot vulnerable to
localStorage / sessionStorage XSS — any injected script can read localStorage directly and exfiltrate the token in full CSRF — the token must be attached manually via JavaScript, so a forged cross-site request has no way to include it automatically
HttpOnly + Secure + SameSite=Strict cookie CSRF, if SameSite is misconfigured or absent XSS — JavaScript cannot read an HttpOnly cookie's value at all, even with a successful script injection
// Issue the JWT as an HttpOnly, Secure, SameSite=Strict cookie rather than a
// JSON field the client is expected to store itself
@PostMapping("/login")
public ResponseEntity<Void> login(@RequestBody LoginRequest request) {
    Authentication auth = authManager.authenticate(
        new UsernamePasswordAuthenticationToken(request.username(), request.password())
    );
    String token = jwtService.generateToken(auth);

    ResponseCookie cookie = ResponseCookie.from("access_token", token)
        .httpOnly(true)          // unreachable from JavaScript, even via a successful XSS payload
        .secure(true)            // never sent over a plain HTTP connection
        .sameSite("Strict")     // not attached to cross-site requests — this is what mitigates CSRF here
        .path("/")
        .maxAge(Duration.ofMinutes(15))
        .build();

    return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()).build();
}
Being honest about the trade-off: SameSite=Strict is doing real work here

HttpOnly closes the XSS exfiltration path completely — this is the stronger, more decisive protection of the two, since XSS vulnerabilities are common and a full token theft is a total account compromise. SameSite=Strict is what keeps this choice from reopening CSRF as a consequence: without it, the browser would still attach the cookie automatically to a forged cross-site request, and unlike the localStorage approach, the attacker wouldn't even need to read the token's value to abuse it — just trigger the request. Strict mode does mean the cookie won't be sent on a top-level navigation arriving from an external site either (a user clicking a link from an email lands logged out and has to sign in again) — Lax is the common compromise when that specific UX cost isn't acceptable, at the cost of allowing the cookie on top-level GET navigations from other origins.

Access + Refresh Tokens — Living With No Instant Revocation

A stateless token can't be individually revoked before its expiry without reintroducing some form of shared state — which is exactly the dependency JWTs exist to avoid. The practical mitigation is to keep the access token short-lived (minutes, as shown above) and pair it with a longer-lived refresh token that is itself tracked and revocable, since it's checked against a store on every use rather than trusted purely on its signature.

@PostMapping("/refresh")
public ResponseEntity<Void> refresh(@CookieValue("refresh_token") String refreshToken) {
    // Refresh tokens ARE looked up against a store — this is the deliberate
    // point where revocability re-enters the design, in exchange for one
    // lookup per refresh instead of one lookup per request.
    RefreshTokenRecord record = refreshTokenRepository.findByToken(refreshToken)
        .filter(r -> !r.isRevoked() && r.getExpiresAt().isAfter(Instant.now()))
        .orElseThrow(() -> new BadCredentialsException("Invalid or expired refresh token"));

    // Rotate on every use: revoke the old refresh token, issue a new one.
    // This limits the damage of a stolen refresh token to a single use window
    // and makes reuse of an already-rotated token a detectable signal of theft.
    record.revoke();
    refreshTokenRepository.save(record);
    String newAccessToken = jwtService.generateToken(record.toAuthentication());
    String newRefreshToken = refreshTokenService.issue(record.getUsername());

    // ... set both as HttpOnly/Secure/SameSite cookies, same as login
    return ResponseEntity.ok().build();
}
TokenLifetimeRevocable before expiry?Verified by
Access tokenMinutesNo — signature-only, stateless verificationAny resource server holding the public key
Refresh tokenDays to weeksYes — looked up against a store on every useThe issuing authorization server only

Best Practices and Common Pitfalls

✅ Do

  • Treat JWT payloads as visible, not secret — never put a password, card number, or other confidential value in a claim
  • Use RS256/ES256 (asymmetric) for tokens verified by more than one service; reserve HS256 for a single service issuing and verifying its own tokens
  • Pin the expected signing algorithm at verification time — never derive it from the token's own header
  • Always validate exp, iss, and aud on every verification, not just the signature
  • Store the token in an HttpOnly, Secure, SameSite cookie rather than localStorage when the client is a browser
  • Keep access tokens short-lived and pair them with a rotating, store-backed refresh token for the revocability a pure JWT can't provide

❌ Don't

  • Don't store a JWT in localStorage "for simplicity" — any successful XSS on the page reads it directly and exfiltrates full account access
  • Don't accept "alg":"none" or let the token header dictate the verification algorithm
  • Don't issue long-lived access tokens "to avoid refresh calls" — that's directly trading away the one mitigation available for the lack of instant revocation
  • Don't skip aud validation in a microservices setup — a token legitimately issued for one service should not be accepted by another that never should have trusted it
  • Don't confuse "the signature is valid" with "this token should be trusted" — expiry, issuer, and audience all still need explicit checks

Interview Questions

🎓 Junior level

Q: Is a JWT encrypted?
No. A JWT's header and payload are base64url-encoded, which is a reversible text representation, not encryption — anyone who has the token can decode and read every claim. The signature only proves the contents weren't altered after the issuer signed them; it provides integrity, not confidentiality.

Q: What are the three parts of a JWT?
Header (algorithm and token type), payload (the claims — subject, expiration, custom data), and signature (computed over the header and payload using the issuer's signing key), joined by dots and each base64url-encoded.

Q: What's the difference between HS256 and RS256?
HS256 is symmetric — the same secret both signs and verifies the token, so every party that needs to verify must also be trusted with the secret that could forge one. RS256 is asymmetric — a private key signs, and a public key verifies; the public key can be distributed freely to any number of verifying services without ever exposing the ability to forge a token.

🔥 Senior level

Q: Explain the RS256-to-HS256 algorithm confusion attack and why it's the verifier's fault, not the algorithm's.
If a service signs tokens with RS256 and publishes its RSA public key (as intended, so other services can verify), a poorly written verifier that trusts the "alg" field from the token's own header rather than hardcoding the expected algorithm can be tricked: an attacker crafts a token with header {"alg":"HS256"} and computes an HMAC-SHA256 signature using the RSA public key's bytes as the HMAC secret. Since the public key is, by definition, public, the attacker can compute that exact signature, and the naive verifier — configured only with "here's a key, verify using whatever algorithm the header says" — accepts it as valid. The fix is architectural: the verifier must bind a specific expected algorithm to a specific key type at configuration time, never derive the algorithm from attacker-controlled input.

Q: A team decides to store JWTs in localStorage because it's simpler than managing cookies, and mitigates the risk by aggressively sanitizing all user input to prevent XSS. Is this an acceptable trade-off?
It's a bet on a single control never failing, in an application large enough that "aggressively sanitizing all user input" is a continuously maintained property, not a one-time achievement — every new feature, every third-party library, every markdown renderer or rich-text editor added later is a fresh opportunity to reintroduce an XSS gap. An HttpOnly cookie removes the token from JavaScript's reach entirely, so a single XSS bug doesn't equal full account takeover even if the sanitization layer eventually fails somewhere. The trade you take on instead is CSRF exposure, which SameSite=Strict (or Lax, with the UX trade-off noted) addresses directly. In most real applications, defense against a category of bug that inevitably recurs (XSS, across a growing codebase) is worth more than defense against one that's fully closed by a single, static configuration flag (CSRF, via SameSite).

Q: Your resource servers verify JWTs entirely offline using a cached public key, with no call back to the authorization server. A customer's account is compromised and you need to immediately cut off their access. What can you actually do, and what can't you?
You cannot invalidate already-issued access tokens before their natural expiry — that capability was traded away for the offline verification property in the first place. What you can do: revoke the customer's refresh token(s) immediately in the store that tracks them, which prevents any new access token from being minted going forward; rely on short access-token lifetimes (minutes) to bound how long the compromised access is still usable regardless; and if the threat model genuinely requires instant, hard revocation even mid-lifetime, that requires adding back a check the offline model was designed to avoid — a lightweight denylist of revoked jti values that resource servers consult, accepting the shared-state dependency for that specific, rare case rather than for every request.