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.
*/
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
| Claim | Meaning |
|---|---|
sub | Subject — the principal this token is about (typically a user or account ID) |
iss | Issuer — which authorization server or service issued this token |
aud | Audience — which service(s) this token is intended for; a resource server should reject tokens not meant for it |
iat | Issued-at timestamp |
exp | Expiration timestamp — a verifier must reject any token past this instant |
jti | JWT 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)
| Family | Example algorithms | Key model | Fits |
|---|---|---|---|
| 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.
| Storage | Vulnerable to | Not 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();
}
SameSite=Strict is doing real work hereHttpOnly 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();
}
| Token | Lifetime | Revocable before expiry? | Verified by |
|---|---|---|---|
| Access token | Minutes | No — signature-only, stateless verification | Any resource server holding the public key |
| Refresh token | Days to weeks | Yes — looked up against a store on every use | The 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, andaudon every verification, not just the signature - Store the token in an
HttpOnly,Secure,SameSitecookie rather thanlocalStoragewhen 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
audvalidation 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
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.
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.