10.3 Review JWT Implementation
10.3 - Review JWT Implementation
JWT implementation review covers how your service issues and validates tokens, not only whether parsing skips signature checks. Start at the authorization server: signing keys, algorithms, claim design, and refresh handling. Then trace every resource server that consumes those tokens. For parse-time flaws and algorithm confusion, also read 4.16 Review JWT Security.
What This Topic Is
This chapter is about implementation review, not generic vulnerability hunting. A secure JWT stack requires correct cryptography, key lifecycle, claim policy, and refresh rotation—not merely calling jwt.decode with a secret string.
The unsafe assumption is that HS256 with a long-lived shared secret scales across many services, or that access tokens can live for days without refresh controls. Weak issuance undermines every downstream validator.
This maps to CWE-347 and CWE-613 (Insufficient Session Expiration) when refresh rotation and revocation are missing.
Vulnerability Characteristics (Where to Identify Them)
| Signal | Where to look |
|---|---|
| Feature type | Custom auth server, API gateway token mint, microservice mesh, mobile backend |
| Signing model | HS256 shared secret copied to every service; private keys in repo; no kid in header |
| JWKS endpoint | Missing /.well-known/jwks.json, stale keys served after rotation, HTTP not HTTPS |
| Access token policy | Lifetime over 15 minutes without justification; sensitive claims in access token |
| Refresh tokens | Reusable refresh tokens, no rotation, no reuse detection, refresh stored in localStorage |
| Validation gaps | Resource servers fetch JWKS once at startup; no iss/aud enforcement per API |
| Revocation | Logout clears client cookie only; no server-side refresh denylist or token version claim |
Abuse Scenarios
Use these when reviewing custom authorization servers and resource APIs that mint or consume JWTs.
Scenario 1: HS256 secret exfiltration → universal forgery
One shared symmetric secret is copied into twenty microservices and a mobile app. An attacker extracts it from any artifact and mints tokens with arbitrary sub, scope, and admin claims.
Scenario 2: Refresh token replay (no rotation)
Refresh tokens are valid until expiry and reusable without bound. XSS steals the refresh token; attacker obtains new access tokens for months without re-authentication.
Scenario 3: Refresh reuse undetected
The server issues a new refresh token on refresh but does not invalidate the previous one. Stolen old refresh tokens continue to work alongside new ones.
Scenario 4: JWKS never refreshed
Resource servers cache JWKS at startup. After key compromise, auth server rotates keys but stale validators accept old kid or fail open to HS256 fallback with a dev secret.
Scenario 5: Missing audience on resource server
API accepts any token signed by the org issuer regardless of aud. Token minted for public web client is replayed to internal admin API.
Scenario 6: Long-lived access token with embedded roles
Access token lifetime is 30 days with roles: ["admin"] inside. Admin lockout or role change has no effect until token expiry.
Language-Specific Libraries and Dangerous Patterns
Python
# Dangerous issuance
jwt.encode({"sub": uid, "admin": True, "exp": now + timedelta(days=7)}, SECRET, algorithm="HS256")
jwt.decode(token, SECRET, algorithms=["HS256", "none"]) # resource server
# Safer: PyJWT RS256 + JWKS endpoint for resource servers
import jwt as pyjwt
from jwt import PyJWKClient
jwks_client = PyJWKClient("https://auth.example.com/.well-known/jwks.json")
signing_key = jwks_client.get_signing_key_from_jwt(raw_token)
pyjwt.decode(
raw_token, signing_key.key, algorithms=["RS256"],
audience="api.example.com", issuer="https://auth.example.com",
)
Also review: python-jose jwt.decode defaults, flask-jwt-extended configuration.
Java
// Dangerous: jjwt HS256 secret in source; 30-day exp
Jwts.builder().setExpiration(thirtyDays).signWith(SignatureAlgorithm.HS256, SECRET);
// Safer: jjwt RS256 + NimbusJwtDecoder with JWK Set URI
Jwts.parserBuilder().setSigningKeyResolver(jwkResolver).requireIssuer("https://auth.example.com").build();
NimbusJwtDecoder.withJwkSetUri("https://auth.example.com/.well-known/jwks.json").build();
Also review: jjwt, Spring Authorization Server, Keycloak adapter configs.
C
// Dangerous
new JwtSecurityToken(..., expires: DateTime.UtcNow.AddDays(30), signingCredentials: hmacCreds);
// Safer: AddJwtBearer with Authority + Audience; RSA signing at auth server
services.AddAuthentication().AddJwtBearer(o => {
o.Authority = "https://auth.example.com";
o.Audience = "api.example.com";
o.TokenValidationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 };
});
Also review: IdentityModel, Azure AD token validation, Microsoft.AspNetCore.Authentication.JwtBearer.
JavaScript
// Dangerous
jwt.sign({ sub: user.id, role: 'admin' }, process.env.JWT_SECRET, { expiresIn: '30d' });
jwt.verify(token, secret); // no aud/iss
// Safer: jose library with JWKS
import * as jose from 'jose';
const JWKS = jose.createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
const { payload } = await jose.jwtVerify(token, JWKS, { issuer: 'https://auth.example.com', audience: 'api.example.com' });
Go
// Dangerous
jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(os.Getenv("JWT_SECRET")))
// Safer: golang-jwt + lestrrat-go/jwx JWKS
token, err := jwt.Parse(raw, jwt.WithKeySet(jwkSet), jwt.WithAudience("api.example.com"), jwt.WithIssuer("https://auth.example.com"))
See PyJWT, jjwt, RFC 8725 JWT BCP, and lestrrat-go/jwx.
Sample Vulnerable Code in Python
import jwt
from datetime import datetime, timedelta
SECRET = "shared-across-twenty-microservices"
def issue_tokens(user_id: str, scopes: list[str]) -> dict:
now = datetime.utcnow()
access = jwt.encode(
{
"sub": user_id,
"scope": " ".join(scopes),
"admin": True, # privilege claim without audience binding
"exp": now + timedelta(days=7),
},
SECRET,
algorithm="HS256",
)
refresh = jwt.encode(
{"sub": user_id, "typ": "refresh", "exp": now + timedelta(days=90)},
SECRET,
algorithm="HS256",
)
return {"access_token": access, "refresh_token": refresh}
def refresh_access_token(refresh_token: str) -> str:
claims = jwt.decode(refresh_token, SECRET, algorithms=["HS256"])
# Same refresh token works forever; no rotation or reuse detection
return issue_tokens(claims["sub"], ["api"])["access_token"]
Step-by-Step Review Walkthrough
- Map issuer and consumers. Identify which component signs tokens and every service that validates them. HS256 requires secret distribution; RS256/ES256 should use public keys via JWKS.
- Review signing key storage. Private keys belong in HSM, KMS, or sealed secrets—not git. Confirm
kidis present and rotates with the key material. - Inspect access token claims. Keep lifetimes short. Put authorization data in scope or custom claims bound to
aud. Avoid embedding long-lived privileges without refresh checks. - Trace JWKS publication. Authorization servers expose current and rollover public keys. Consumers cache JWKS with TTL and refresh on unknown
kid. - Review refresh flow. Each refresh should mint a new refresh token, invalidate the previous one, and detect reuse (revoke token family on replay).
- Check resource server validation. Each API validates
iss,aud, signature, andexpwith the correct key—not a copy-pasted dev secret. - Confirm logout and compromise response. Document how operators revoke sessions: refresh denylist,
jtiblocklist, or session version claim bumped on password change.
Risk Impact Analysis
Wide-scale forgery. A leaked HS256 secret or stolen private key lets attackers mint valid tokens for any subject and scope.
Stale key trust. Services that never refresh JWKS continue trusting compromised keys after rotation delays exposure but do not stop active abuse if rotation is skipped.
Refresh token replay. Non-rotating refresh tokens act like long-lived passwords; XSS or device theft yields persistent access.
Privilege sprawl. Overlong access tokens with embedded roles delay revocation until expiry even after admin lockout.
Cross-service audience confusion. Tokens minted for one API accepted by another when aud is not enforced per resource server.
Vulnerable Examples in Other Languages
Java
@Service
public class TokenService {
private static final String SECRET = "prod-secret-in-source";
public String mintAccessToken(User user) {
return Jwts.builder()
.setSubject(user.getId())
.claim("roles", user.getRoles())
.setExpiration(Date.from(Instant.now().plus(30, ChronoUnit.DAYS)))
.signWith(SignatureAlgorithm.HS256, SECRET)
.compact();
}
public String refresh(String refreshToken) {
Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(refreshToken).getBody();
return mintAccessToken(userRepo.findById(claims.getSubject()));
// refresh token not rotated; reuse undetected
}
}
C
public string IssueAccessToken(string userId)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: "auth.example.com",
claims: new[] { new Claim("sub", userId), new Claim("role", "admin") },
expires: DateTime.UtcNow.AddDays(1),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
// No audience; RS256 not used; JWKS not published
}
JavaScript
import jwt from "jsonwebtoken";
const PRIVATE_KEY = process.env.JWT_SECRET; // symmetric secret for all services
export function issuePair(user) {
const access = jwt.sign({ sub: user.id, scope: "api" }, PRIVATE_KEY, { expiresIn: "24h" });
const refresh = jwt.sign({ sub: user.id, typ: "refresh" }, PRIVATE_KEY, { expiresIn: "180d" });
return { access, refresh };
}
export function rotateRefresh(oldRefresh) {
const payload = jwt.verify(oldRefresh, PRIVATE_KEY);
return issuePair({ id: payload.sub }); // new refresh issued; old still valid
}
Go
func mint(sub string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": sub,
"exp": time.Now().Add(72 * time.Hour).Unix(),
})
return token.SignedString([]byte(os.Getenv("JWT_SECRET")))
}
func jwksHandler(w http.ResponseWriter, r *http.Request) {
// Authorization server has no JWKS endpoint; RS256 not supported
http.Error(w, "not found", http.StatusNotFound)
}
Fix: Safer Patterns and Libraries to Use
Python
Sign with RS256 using PyJWT and expose JWKS. Rotate refresh tokens and detect reuse.
import jwt as pyjwt
import secrets
import time
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
PRIVATE_KEY = load_private_key_from_kms()
PUBLIC_KEY = PRIVATE_KEY.public_key()
KID = "2026-05-key-1"
def issue_tokens(user_id: str, scopes: list[str], refresh_family: str | None = None) -> dict:
now = int(time.time())
access = pyjwt.encode(
{
"iss": "https://auth.example.com",
"sub": user_id,
"aud": "api.example.com",
"scope": " ".join(scopes),
"iat": now,
"exp": now + 900,
},
PRIVATE_KEY,
algorithm="RS256",
headers={"kid": KID},
)
family = refresh_family or secrets.token_urlsafe(16)
refresh_jti = secrets.token_urlsafe(16)
refresh = pyjwt.encode(
{
"iss": "https://auth.example.com",
"sub": user_id,
"aud": "auth.example.com",
"jti": refresh_jti,
"family": family,
"iat": now,
"exp": now + 604800,
},
PRIVATE_KEY,
algorithm="RS256",
headers={"kid": KID},
)
store_refresh(refresh_jti, family, user_id)
return {"access_token": access, "refresh_token": refresh}
def refresh_tokens(presented_refresh: str) -> dict:
claims = validate_refresh(presented_refresh) # RS256 + iss/aud/exp/jti via PyJWT
if is_refresh_reused(claims["jti"], claims["family"]):
revoke_family(claims["family"])
raise AuthError("refresh reuse detected")
invalidate_refresh(claims["jti"])
return issue_tokens(claims["sub"], ["api"], refresh_family=claims["family"])
Important: Resource servers validate with your JWKS URL and required aud. Pair with 4.16 checks for algorithm allowlists and none rejection.
Java
Use Nimbus with RSA keys and publish JWKS from the authorization server.
RSAKey rsaKey = new RSAKey.Builder(publicKey, privateKey)
.keyID("2026-05-key-1")
.algorithm(JWSAlgorithm.RS256)
.build();
JWKSet jwkSet = new JWKSet(rsaKey.toPublicJWK());
SignedJWT access = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(),
new JWTClaimsSet.Builder()
.issuer("https://auth.example.com")
.subject(userId)
.audience("api.example.com")
.expirationTime(Date.from(Instant.now().plusSeconds(900)))
.claim("scope", scopes)
.build());
access.sign(new RSASSASigner(rsaKey));
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(
"https://auth.example.com/.well-known/jwks.json").build();
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer("https://auth.example.com"));
return decoder;
}
Important: Enable refresh token rotation in Spring Authorization Server or your custom store with reuse detection. Bump a token_version user claim on password reset.
C
Use RSA credentials and AddJwtBearer with authority metadata for resource APIs.
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://auth.example.com";
options.Audience = "api.example.com";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
};
});
var rsa = RSA.Create(2048);
var signingCredentials = new SigningCredentials(
new RsaSecurityKey(rsa) { KeyId = "2026-05-key-1" },
SecurityAlgorithms.RsaSha256);
services.AddSingleton<IJwksProvider>(new JwksProvider(rsa.ExportParameters(false), "2026-05-key-1"));
Important: Publish JWKS from the auth service. Store refresh tokens hashed server-side and replace them on each refresh request.
Go
Use lestrrat-go/jwx or golang-jwt/jwt with RSA and a JWKS HTTP handler.
import "github.com/lestrrat-go/jwx/v2/jwk"
key, _ := rsa.GenerateKey(rand.Reader, 2048)
jwkKey, _ := jwk.FromRaw(key)
jwkKey.Set(jwk.KeyIDKey, "2026-05-key-1")
jwkKey.Set(jwk.AlgorithmKey, jwk.RS256)
func jwksHandler(w http.ResponseWriter, r *http.Request) {
set := jwk.NewSet()
set.AddKey(jwkKey.PublicKey())
json.NewEncoder(w).Encode(set)
}
func validateAccess(raw string) (jwt.MapClaims, error) {
set, _ := jwk.Fetch(context.Background(), "https://auth.example.com/.well-known/jwks.json")
token, err := jwt.Parse(raw, jwt.WithKeySet(set, jws.WithRequireKid(true)),
jwt.WithIssuer("https://auth.example.com"), jwt.WithAudience("api.example.com"))
// ...
}
Important: Cache JWKS with HTTP cache headers and refetch when kid is unknown. Treat refresh reuse as a full session compromise signal.
Verify During Review
- Authorization server signs with asymmetric keys (RS256/ES256) and publishes JWKS with
kid. - Access tokens are short-lived; refresh tokens rotate and trigger reuse detection.
- Every resource server validates signature, iss, aud, exp against current JWKS—not a shared HS256 secret.
- Private keys live in KMS/HSM; rotation plan updates JWKS without invalidating all sessions instantly unless required.
- Logout and account recovery invalidate refresh families or bump session version claims.
- Cross-check 4.16 Review JWT Security for consumer-side parse and algorithm flaws.
Reference
- RFC 7519: JSON Web Token
- RFC 7517: JSON Web Key
- RFC 8725: JWT Best Current Practices
- OAuth 2.0 Authorization Framework — Refresh Token
- OAuth 2.0 Token Exchange and Rotation Practices (RFC 9700 BCP)
- OWASP JWT Cheat Sheet
- PyJWT documentation
- PyJWT PyJWKClient
- Spring Authorization Server
- Microsoft identity — Token validation
- lestrrat-go/jwx