JWT #
JWT (JSON Web Token) is one of the most widely used authentication mechanisms in modern APIs — and also one of the most often implemented incorrectly. Many engineers adopt JWT just because “everyone uses it”, without truly understanding its security trade-offs: unencrypted payloads, tokens that can’t simply be revoked, and the risks of long-lived tokens. This article covers JWT from its fundamental structure, algorithm choices and their implications, access tokens vs refresh tokens, secure storage methods, and when session-based auth is actually the better choice.
JWT Anatomy #
A JWT is a string in header.payload.signature format — three dot-separated parts, each encoded in Base64URL.
eyJhbG...VCJ9
.
eyJzdW...AwfQ
.
[signature]
Header #
The header contains metadata about the token itself — the signing algorithm and token type.
{
"alg": "RS256",
"typ": "JWT"
}
The alg field is very important and will be discussed in depth in the algorithms section. It isn’t just a label — it determines how the signature is created and verified.
Payload (Claims) #
The payload contains claims — statements about an entity (usually a user) and additional data. There are three claim categories:
{
"sub": "usr_123", // Registered: subject (who this user is)
"iss": "auth.example.com", // Registered: issuer (who issued it)
"aud": "api.example.com", // Registered: audience (who this token is for)
"exp": 1706356000, // Registered: expiry (when it expires, Unix timestamp)
"iat": 1706352400, // Registered: issued at (when it was issued)
"nbf": 1706352400, // Registered: not before (invalid before this time)
"jti": "unique-token-id", // Registered: JWT ID (unique identifier for revocation)
"name": "Budi Santoso", // Public claim
"email": "[email protected]",
"role": "user", // Private claim (application custom)
"permissions": ["read:orders", "write:orders"]
}
A JWT payload is not encrypted — it’s only Base64URL encoded, not encrypted. Anyone holding the token can read its contents by decoding the second part. Never store passwords, credit card numbers, or other sensitive information in a JWT payload.
Signature #
The signature proves two things: that the token was truly issued by the claiming party (issuer integrity), and that its content hasn’t been altered since issuance (data integrity).
// For HMAC (symmetric):
signature = HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret_key
)
// For RSA (asymmetric):
signature = RSA_SHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
private_key
)
// Verification uses a different public key
Algorithm Choice — HS256 vs RS256 vs ES256 #
The algorithm choice is one of the most important decisions when implementing JWT. Each algorithm has different trade-offs.
flowchart TD
Start["Need a signing algorithm for JWT"]
Q1{"How many\nservices\nverify the token?"}
Q2{"Is performance\nthe main constraint?"}
HS256["HS256\n(HMAC SHA-256)\nSymmetric — one secret key\nfor both sign and verify"]
RS256["RS256\n(RSA SHA-256)\nAsymmetric — private key signs,\npublic key verifies"]
ES256["ES256\n(ECDSA SHA-256)\nAsymmetric — smaller than RSA,\nbetter performance"]
Start --> Q1
Q1 -->|"Only one service\nor full trust"| HS256
Q1 -->|"Many different\nservices"| Q2
Q2 -->|"Key size and\nperformance matter"| ES256
Q2 -->|"Compatibility\nmatters more"| RS256
style HS256 fill:#E67E22,color:#fff,stroke:#D35400
style RS256 fill:#27AE60,color:#fff,stroke:#1E8449
style ES256 fill:#2980B9,color:#fff,stroke:#1A5276HS256 — Symmetric, One Secret Key #
Signing and verification use the same secret key. Simple but dangerous in multi-service environments.
// ANTI-PATTERN: HS256 in a multi-service environment
Auth Service: issues tokens with secret_key
Order Service: needs verification → must have the same secret_key
Payment Service: needs verification → must have the same secret_key
→ If one service is compromised, all services can issue valid tokens
→ The secret key must be distributed to all services
// CORRECT: HS256 only for single-service apps or monoliths
Auth Service → issues tokens
API Service (same deployment) → verifies with the same secret
→ No need to distribute the secret to other parties
RS256 — Asymmetric, Private/Public Key Pair #
The auth service issues tokens using a private key; other services verify using a public key. The public key can be freely shared — only the private key must be protected.
// CORRECT: RS256 for multi-service
Auth Service: signs with private_key (stored safely, never shared)
Order Service: verifies with public_key (accessible to anyone)
Payment Service: verifies with public_key
Analytics Service: verifies with public_key
→ Compromising one service doesn't let them issue valid tokens
→ Only the Auth Service can issue tokens
// Common public key distribution method:
Auth Service exposes an endpoint: GET /.well-known/jwks.json
→ Other services fetch the public key from here
→ Key rotation can happen without manual coordination
ES256 — Asymmetric, ECDSA #
Like RS256 but using Elliptic Curve Cryptography. The resulting tokens are smaller (~60% smaller than RSA) and operations are faster, but compatibility with older libraries is more limited.
Signature size comparison:
HS256: ~43 bytes (Base64URL)
RS256: ~342 bytes (Base64URL) — RSA signatures are large
ES256: ~86 bytes (Base64URL) — far smaller than RSA
For APIs receiving millions of requests per day with a JWT in every header,
this size difference means significant bandwidth savings.
How JWT Works in Authentication #
sequenceDiagram
participant U as User / Client
participant AS as Auth Service
participant API as API Service
participant DB as Database
Note over U,DB: Login and get tokens
U->>AS: POST /auth/login { email, password }
AS->>DB: Verify credentials
DB-->>AS: User data
AS-->>U: { access_token (15 min), refresh_token (7 days) }
Note over U,API: Requests with the access token
U->>API: GET /api/orders\nAuthorization: Bearer ***
API->>API: Verify JWT signature\n(no database call needed)
API-->>U: Response data
Note over U,AS: Access token expired — use the refresh token
U->>AS: POST /auth/refresh\n{ refresh_token }
AS->>DB: Verify refresh token (still valid?)
DB-->>AS: Valid
AS-->>U: { new access_token (15 min), new refresh_token (7 days) }
Note over U,AS: Logout — invalidate the refresh token
U->>AS: POST /auth/logout\n{ refresh_token }
AS->>DB: Delete/invalidate the refresh token
DB-->>AS: Done
AS-->>U: 200 OKThe main JWT advantage visible from this diagram: the API Service doesn’t need a database call for every request. It verifies the signature locally using the public key — far faster than session-based auth which needs a session store query.
Access Tokens and Refresh Tokens #
Separating access and refresh tokens is the foundation of secure JWT implementation. This isn’t just a best practice — it’s a necessity because JWTs can’t simply be revoked.
Access Token:
Function: carries identity and permissions for every API request
Duration: SHORT — 5 to 15 minutes
Stored: in memory (JavaScript variables, not localStorage)
Reason for short duration: if leaked, the attack window is limited
Refresh Token:
Function: gets a new access token after expiry
Duration: LONG — 7 to 30 days (or until logout)
Stored: in an HttpOnly Secure Cookie (inaccessible to JavaScript)
Reason: can't be stolen via XSS because of HttpOnly
// ANTI-PATTERN: Long-lived access tokens
{
"sub": "usr_123",
"exp": 1738887600 // expires in 30 days
}
→ If the token leaks, the attacker has 30 days of access
→ No way to revoke access faster
// CORRECT: Short access token + refresh token
Access token: exp = now + 15 minutes
Refresh token: stored in the DB, revocable anytime (logout, suspicious activity)
→ If the access token leaks, only a 15-minute window
→ If the refresh token leaks, invalidate it in the DB immediately
Token Rotation — Preventing Refresh Token Theft #
Token rotation is a strategy where every refresh token use issues a new one. If an old refresh token is used again (by an attacker who stole it), the system detects the double use and invalidates all of that user’s tokens.
sequenceDiagram
participant U as User (Legit)
participant A as Attacker
participant AS as Auth Service
Note over A: Attacker successfully stole refresh_token_v1
U->>AS: Uses refresh_token_v1
AS-->>U: new access_token + refresh_token_v2
Note over AS: refresh_token_v1 invalidated
A->>AS: Tries to use refresh_token_v1 (stolen)
AS->>AS: Detected: token already used!\nPossible token theft!
AS->>AS: Invalidate ALL of this user's refresh tokens
AS-->>A: 401 Unauthorized
Note over U: User is force logged out of all devices
U->>U: Must log in again
Note over U: Safe — the attacker can't get new accessToken rotation implementation:
1. Every refresh token has a unique ID and is stored in the database
2. When a refresh token is used:
a. Mark the old token as "used"
b. Issue a new access token + a new refresh token
3. If an already-"used" refresh token is used again:
a. Invalidate ALL of this user's active refresh tokens
b. Log it as a security event
c. Optional: send a notification to the user
Revocation — How to Revoke an Issued JWT #
This is one of JWT’s fundamental limitations: an access token can’t be revoked before expiry. You must mitigate this with a combination of short durations and several revocation strategies.
Strategy 1: Very short access token durations (recommended)
Access token: 5-15 minutes
→ If leaked, the attack window is very small
→ No revocation needed for the majority of use cases
Strategy 2: Token blacklists (if immediate revocation is needed)
Store revoked JTIs (JWT IDs) in Redis/cache
Every request: check whether the JTI is on the blacklist
Clean up blacklist entries after the token naturally expires
// Blacklist check in middleware
func verifyJWT(tokenString string) (*Claims, error) {
claims, err := parseJWT(tokenString)
if err != nil {
return nil, err
}
// Blacklist check
if isBlacklisted(claims.JTI) {
return nil, ErrTokenRevoked
}
return claims, nil
}
Weakness: every request needs a Redis query
→ Eliminates one of JWT's main advantages (statelessness)
→ For use cases needing immediate revocation, consider sessions
Strategy 3: Refresh token invalidation (for logout)
Logout = delete the refresh token from the DB
The old access token stays valid until expiry (5-15 minutes)
→ Acceptable for most cases
Storing Tokens Securely #
Where tokens are stored determines what attack types are possible. This is an often underestimated decision.
Storage options and their risks:
localStorage / sessionStorage:
Risk: VERY DANGEROUS for refresh tokens
Problem: accessible to JavaScript → vulnerable to XSS
If there's an XSS vulnerability, attackers can steal tokens
→ NEVER store refresh tokens in localStorage
For very short-lived access tokens (< 5 minutes):
→ Acceptable because the attack window is small
→ But in-memory is still better
Memory (JavaScript variables):
Risk: LOW
Weakness: lost on page refresh
→ Ideal for access tokens
→ Not persistent, but that's what we want
HttpOnly Secure Cookie:
Risk: VERY LOW for XSS (JavaScript can't read it)
Needs CSRF protection (use SameSite=Strict or CSRF tokens)
→ Ideal for refresh tokens in web apps
Secure Storage (Mobile):
iOS: Keychain
Android: Keystore
→ Use for all tokens in mobile apps
// Secure cookie configuration for refresh tokens (server-side)
Set-Cookie: refresh_token=<value>;
HttpOnly; // inaccessible to JavaScript
Secure; // only sent over HTTPS
SameSite=Strict; // not sent on cross-site requests (CSRF protection)
Path=/auth; // only sent to /auth/* endpoints
Max-Age=604800; // 7 days
Strict Claim Validation #
Signature verification alone isn’t enough. The claims in the payload must also be strictly validated.
// Example of complete JWT validation in Go
func validateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{},
func(token *jwt.Token) (interface{}, error) {
// 1. Validate the algorithm — MANDATORY
// Prevents "algorithm confusion attacks" (none algorithm)
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v",
token.Header["alg"])
}
return publicKey, nil
},
)
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
// 2. Validate exp — libraries usually check this already
if claims.ExpiresAt.Before(time.Now()) {
return nil, ErrTokenExpired
}
// 3. Validate the issuer — MANDATORY
if claims.Issuer != "auth.example.com" {
return nil, ErrInvalidIssuer
}
// 4. Validate the audience — MANDATORY if there are multiple services
if !claims.VerifyAudience("api.example.com", true) {
return nil, ErrInvalidAudience
}
// 5. Validate nbf (not before) — optional but recommended
if claims.NotBefore != nil && time.Now().Before(claims.NotBefore.Time) {
return nil, ErrTokenNotYetValid
}
return claims, nil
}
Algorithm Confusion Attacks are real and have caused many security breaches. Attackers changealgin the header to"none"and strip the signature — if the server doesn’t strictly validate the algorithm, unsigned tokens are accepted as valid. Always whitelist allowed algorithms and reject tokens with unknown algorithms or"none".
JWT vs Session-Based Auth — When to Choose Which #
JWT and session-based auth aren’t rivals — they’re different trade-offs for different needs.
flowchart TD
Start["Need an\nauthentication mechanism"]
Q1{"Need immediate\nrevocation?\n(force logout, ban users)"}
Q2{"Is the system\ndistributed?\n(multiple services)"}
Q3{"Large scale?\nNeed statelessness?"}
Session["Session-Based Auth\n→ Easy to revoke\n→ Simple for monoliths\n→ Needs a shared session store\n if distributed"]
JWT["JWT\n→ Stateless, scalable\n→ Self-contained\n→ Revocation needs workarounds"]
Both["Hybrid:\nShort JWT access tokens\n+ Sessions for revocation\nor opaque refresh tokens"]
Start --> Q1
Q1 -->|"Yes, must be able\nto revoke immediately"| Q2
Q1 -->|"No, a 15-minute\nwindow is fine"| JWT
Q2 -->|"Yes, microservices"| JWT
Q2 -->|"No, monolith"| Session
JWT --> Q3
Q3 -->|"Also need immediate\nrevocation"| Both
style Session fill:#27AE60,color:#fff
style JWT fill:#2980B9,color:#fff
style Both fill:#8E44AD,color:#fffUse JWT if:
✓ APIs consumed by many different services
✓ Mobile apps (can't easily manage server-side sessions)
✓ Distributed systems or microservices
✓ SSO (Single Sign-On) across multiple apps
✓ Statelessness is a requirement (cloud-native, serverless)
Use Sessions if:
✓ Simple monolithic web applications
✓ Immediate revocation is needed (force logout, user bans)
✓ The team isn't familiar with JWT security pitfalls
✓ No cross-service authentication needs
The Most Common Implementation Mistakes #
// ✗ Mistake 1: Storing refresh tokens in localStorage
localStorage.setItem('refresh_token', token)
// Vulnerable to XSS — attackers can steal tokens by injecting scripts
// ✓ Solution: HttpOnly cookies for refresh tokens, memory for access tokens
---
// ✗ Mistake 2: Long-lived access tokens
{ "exp": now + 30 days }
// If leaked, attackers have 30 days of access with no way to revoke
// ✓ Solution: 5-15 minute access tokens, refresh tokens in HttpOnly cookies
---
// ✗ Mistake 3: Not validating the algorithm
jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return publicKey, nil // no algorithm check!
})
// Vulnerable to algorithm confusion attacks (alg: "none")
// ✓ Solution: Always whitelist allowed algorithms
---
// ✗ Mistake 4: Storing sensitive data in the payload
{
"sub": "usr_123",
"password_hash": "$2b$10$...", // NEVER
"credit_card": "4111111111111111" // NEVER
}
// The payload is readable by anyone holding the token
// ✓ Solution: Only store identifiers and non-sensitive claims
---
// ✗ Mistake 5: Not validating iss and aud
// Tokens from other services or attackers are accepted if the signature is valid
// ✓ Solution: Always explicitly validate issuer and audience
---
// ✗ Mistake 6: HS256 for multi-service
// All services need the same secret key
// One compromised service = all services compromised
// ✓ Solution: RS256 or ES256 for multi-service
JWT Security Checklist #
TOKEN DESIGN:
□ Access tokens short-lived (at most 15 minutes for sensitive APIs)
□ Refresh tokens longer-lived but revocable
□ JTI (JWT ID) present in every token for tracking
□ iss and aud claims filled with specific values
ALGORITHM:
□ RS256 or ES256 for multi-service (not HS256)
□ Private keys stored securely, not hardcoded in code
□ Public keys distributed via a JWKS endpoint
□ Key rotation scheduled periodically
SERVER-SIDE VALIDATION:
□ Algorithm explicitly validated (whitelist, reject "none")
□ exp validated (libraries usually handle this)
□ iss validated against a value hardcoded in configuration
□ aud validated against a value hardcoded in configuration
□ nbf validated if present
CLIENT-SIDE STORAGE:
□ Refresh tokens in HttpOnly + Secure + SameSite cookies
□ Access tokens in memory (not localStorage)
□ Mobile apps use secure storage (Keychain/Keystore)
REFRESH TOKENS:
□ Token rotation implemented
□ Reuse detection exists (and invalidates all of a user's tokens if detected)
□ Refresh tokens revocable via a logout endpoint
□ Refresh tokens stored in the database for tracking
ADDITIONAL SECURITY:
□ HTTPS mandatory in all environments (not just production)
□ No sensitive information in payloads
□ Rate limiting on login and refresh endpoints
□ Every event logged: login, refresh, logout, failed attempts
Summary #
- JWT payloads aren’t encrypted — they’re only Base64URL encoded. Anyone holding the token can read its contents. Never store passwords, credit cards, or sensitive data in payloads.
- Choose RS256 or ES256 for multi-service, HS256 only for single-service — asymmetric signing lets other services verify tokens without gaining the ability to issue new ones.
- Access tokens must be short-lived, refresh tokens in HttpOnly cookies — 5-15 minute access tokens in memory, refresh tokens in HttpOnly Secure SameSite cookies. This combination protects against both XSS and CSRF.
- Token rotation is defense in depth against refresh token theft — every refresh token use issues a new token, and old-token reuse detects possible theft and triggers full invalidation.
- JWTs can’t be directly revoked — mitigate with short durations, revocable refresh tokens, or JTI-based blacklists when immediate revocation is needed. If immediate revocation is a primary requirement, consider session-based auth.
- Algorithm confusion attacks are real and dangerous — always whitelist allowed algorithms and reject tokens with
alg: "none" or unknown algorithms. - Validation is more than just signatures — explicitly validate
exp, iss, aud, and nbf. A signature-valid token from the wrong issuer is still dangerous. - Session-based auth isn’t outdated — for monolithic apps needing immediate revocation and no cross-service auth, sessions are simpler and safer than poorly implemented JWT.
- Rate limit all authentication endpoints — login, refresh, and password reset endpoints are brute force targets. Rate limiting is the minimum defense.
- Log every authentication event — successful logins, failed logins, refresh tokens, logouts, and anomalies like reuse detection. A good audit trail is the foundation of effective incident response.
#
- JWT payloads aren’t encrypted — they’re only Base64URL encoded. Anyone holding the token can read its contents. Never store passwords, credit cards, or sensitive data in payloads.
- Choose RS256 or ES256 for multi-service, HS256 only for single-service — asymmetric signing lets other services verify tokens without gaining the ability to issue new ones.
- Access tokens must be short-lived, refresh tokens in HttpOnly cookies — 5-15 minute access tokens in memory, refresh tokens in HttpOnly Secure SameSite cookies. This combination protects against both XSS and CSRF.
- Token rotation is defense in depth against refresh token theft — every refresh token use issues a new token, and old-token reuse detects possible theft and triggers full invalidation.
- JWTs can’t be directly revoked — mitigate with short durations, revocable refresh tokens, or JTI-based blacklists when immediate revocation is needed. If immediate revocation is a primary requirement, consider session-based auth.
- Algorithm confusion attacks are real and dangerous — always whitelist allowed algorithms and reject tokens with
alg: "none"or unknown algorithms. - Validation is more than just signatures — explicitly validate
exp,iss,aud, andnbf. A signature-valid token from the wrong issuer is still dangerous. - Session-based auth isn’t outdated — for monolithic apps needing immediate revocation and no cross-service auth, sessions are simpler and safer than poorly implemented JWT.
- Rate limit all authentication endpoints — login, refresh, and password reset endpoints are brute force targets. Rate limiting is the minimum defense.
- Log every authentication event — successful logins, failed logins, refresh tokens, logouts, and anomalies like reuse detection. A good audit trail is the foundation of effective incident response.