What Is a JWT? RFC 7519 Explained
A JSON Web Token (JWT) is a compact, URL-safe token format defined in RFC 7519 (May 2015). A JWT encodes a set of claims — name/value pairs that assert facts about a subject (typically a user) — in a tamper-evident way. Any party with the verification key can confirm that the token was issued by a trusted source and has not been modified in transit.
JWTs are used pervasively in:
- OAuth 2.0 / OpenID Connect — access tokens, ID tokens, and refresh tokens
- Stateless session tokens — the server issues a JWT at login; subsequent requests include it in the
Authorization: Bearer <token>header - API-to-API authentication — microservices authenticate to each other using short-lived signed JWTs
- Email verification and password reset links — a signed JWT encodes the user ID and an expiry, eliminating the need for a database lookup on the token
JWT Structure: Header, Payload, and Signature
A JWT is three Base64URL-encoded JSON objects joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← Header
.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBbGljZSIsImV4cCI6MTcxNjU2MDAwMH0 ← Payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
Header
The header declares the token type and the signing algorithm:
{ "alg": "HS256", "typ": "JWT" }
Payload (Claims)
RFC 7519 defines seven registered claim names. All are optional but their semantics are standardised:
iss(Issuer) — who issued the token, e.g."https://auth.example.com"sub(Subject) — who the token is about, e.g. a user IDaud(Audience) — the intended recipient(s) of the tokenexp(Expiration Time) — Unix timestamp after which the token must be rejectednbf(Not Before) — Unix timestamp before which the token must be rejectediat(Issued At) — Unix timestamp when the token was mintedjti(JWT ID) — a unique identifier for the token, useful for revocation
You can add any additional claims you need — these are called private claims.
Signature
The signature is computed over the Base64URL-encoded header and payload joined by a dot: HMACSHA256(base64url(header) + "." + base64url(payload), secret). Any modification to the header or payload invalidates the signature, making tampering detectable.
Important: Base64URL encoding is not encryption. The payload is readable by anyone who holds the token. Never put passwords, credit card numbers, or secrets in a JWT payload unless you are using JWE (JSON Web Encryption, RFC 7516).
Signing Algorithms: HS256, RS256, ES256 and When to Use Each
The alg header field specifies the algorithm used to sign the token. Your choice of algorithm has significant security and operational implications.
- HS256 / HS384 / HS512 (HMAC + SHA-2) — Symmetric algorithms. The same secret key is used to sign and to verify. Simple to implement. The secret must be shared between every service that needs to verify tokens. If the secret leaks, every token ever signed with it is compromised. Use when you control both the issuer and all verifiers and they run in the same trust boundary.
- RS256 / RS384 / RS512 (RSA + SHA-2) — Asymmetric. The issuer signs with a private key; verifiers check with the corresponding public key. Public keys can be distributed openly (typically via a JWKS endpoint). Slower to sign and verify than HMAC. Minimum recommended key size: 2048 bits. Use when multiple services need to verify tokens but should not be able to issue them.
- ES256 / ES384 / ES512 (ECDSA + SHA-2) — Asymmetric, elliptic-curve. Smaller key sizes than RSA for equivalent security (ES256 ≈ RSA-3072). Faster verification. Preferred for mobile and IoT where key storage is constrained. ES256 uses the NIST P-256 curve.
- PS256 / PS384 / PS512 (RSA-PSS) — Probabilistic RSA signing. More secure than RS256 due to randomised padding, but less widely supported. Prefer ES256 for new systems.
The "none" algorithm — which produces an unsigned token — should be explicitly rejected by all verifiers. Several high-profile CVEs have exploited libraries that accepted "alg": "none" tokens as valid.
The 7 Most Common JWT Errors and How to Fix Them
1. TokenExpiredError — the most common
The exp claim has passed. The fix is to refresh the token (if you have a refresh token) or prompt the user to log in again. Don't extend exp on the fly — that defeats the purpose of expiration.
2. JsonWebTokenError: invalid signature
Common causes: using the wrong secret/key, verifying with a public key when the token was signed with a different private key, or the token was tampered with. Check that the alg in the header matches the algorithm your verifier expects.
3. Algorithm confusion (critical security bug)
If a verifier accepts any algorithm specified in the header rather than enforcing the expected algorithm, an attacker can change "alg": "RS256" to "alg": "HS256" and sign the token with the public key (which is, by definition, not a secret). Always verify the algorithm explicitly:
// ❌ Vulnerable — trusts the header's alg claim
jwt.verify(token, publicKey)
// ✅ Safe — enforces the algorithm
jwt.verify(token, publicKey, { algorithms: ['RS256'] })
4. Missing audience validation
Without validating aud, a token issued for Service A can be replayed against Service B. Always specify and verify the audience claim.
5. Storing JWTs in localStorage
localStorage is accessible to any JavaScript running on the page, making it vulnerable to XSS attacks. Store tokens in HttpOnly cookies (not accessible to JavaScript), which are immune to XSS but require CSRF protection. This is the recommended approach for browser-based applications.
6. Excessively long expiry
JWTs are stateless — a valid token cannot be invalidated without a blocklist (which reintroduces statefulness). Keep access token expiry short (15 minutes is common) and use refresh tokens for session persistence. Long-lived access tokens (days or weeks) are a significant security risk if they leak.
7. Including sensitive data in the payload
The payload is Base64URL-encoded, not encrypted. Anyone who intercepts the token can decode and read it. Never include passwords, SSNs, credit card numbers, or any other sensitive data in a JWT payload. For encrypted tokens, use JWE (RFC 7516) with an appropriate content encryption algorithm like A256GCM.
Creating and Verifying JWTs in 5 Languages
import jwt from 'jsonwebtoken';
const secret = process.env.JWT_SECRET; // min 32 random bytes
// Sign
const token = jwt.sign(
{ sub: 'user_123', name: 'Alice', role: 'admin' },
secret,
{ algorithm: 'HS256', expiresIn: '15m', audience: 'api.example.com' }
);
// Verify
try {
const payload = jwt.verify(token, secret, {
algorithms: ['HS256'],
audience: 'api.example.com',
});
console.log(payload.sub); // 'user_123'
} catch (err) {
if (err.name === 'TokenExpiredError') {
// prompt re-login or use refresh token
}
}import jwt
import os
from datetime import datetime, timedelta, timezone
secret = os.environ['JWT_SECRET']
# Sign
payload = {
'sub': 'user_123',
'name': 'Alice',
'exp': datetime.now(timezone.utc) + timedelta(minutes=15),
'aud': 'api.example.com',
}
token = jwt.encode(payload, secret, algorithm='HS256')
# Verify
try:
data = jwt.decode(
token, secret,
algorithms=['HS256'],
audience='api.example.com'
)
print(data['sub']) # 'user_123'
except jwt.ExpiredSignatureError:
print('Token expired')
except jwt.InvalidTokenError as e:
print(f'Invalid token: {e}')package main
import (
"fmt"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
func main() {
secret := []byte(os.Getenv("JWT_SECRET"))
// Sign
claims := jwt.MapClaims{
"sub": "user_123",
"name": "Alice",
"exp": time.Now().Add(15 * time.Minute).Unix(),
"aud": "api.example.com",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, _ := token.SignedString(secret)
// Verify
parsed, err := jwt.Parse(signed, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected alg: %v", t.Header["alg"])
}
return secret, nil
}, jwt.WithAudience("api.example.com"))
if err == nil && parsed.Valid {
sub, _ := parsed.Claims.GetSubject()
fmt.Println(sub) // user_123
}
}import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Date;
SecretKey key = Keys.hmacShaKeyFor(
System.getenv("JWT_SECRET").getBytes()
);
// Sign
String token = Jwts.builder()
.subject("user_123")
.claim("name", "Alice")
.audience().add("api.example.com").and()
.expiration(new Date(System.currentTimeMillis() + 900_000))
.signWith(key)
.compact();
// Verify
try {
Jws<Claims> claims = Jwts.parser()
.verifyWith(key)
.requireAudience("api.example.com")
.build()
.parseSignedClaims(token);
System.out.println(claims.getPayload().getSubject()); // user_123
} catch (JwtException e) {
System.err.println("Invalid JWT: " + e.getMessage());
}# Install: cargo install jwt-cli
# or: brew install mike-engel/jwt-cli/jwt-cli
# Decode (no verification — inspect only)
jwt decode eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.xxx
# Decode and verify signature
jwt decode --secret "$JWT_SECRET" <token>
# Create a token (HS256, expires in 15 minutes)
jwt encode --secret "$JWT_SECRET" --exp='+15 minutes' '{"sub":"user_123","name":"Alice"}'