12 KiB
JWT Security Best Practices
This reference provides comprehensive guidance for reviewing JWT (JSON Web Token) implementation and usage in pull requests.
Critical Security Principles
1. Never Forward JWTs to Unintended Services
The Problem:
A JWT contains an aud (audience) claim that specifies which service(s) the token is intended for. Forwarding a JWT to a service not listed in the audience claim creates serious security vulnerabilities.
Security Impact:
- Confused Deputy Attack: The receiving service cannot verify that the forwarding service is authorized to act on behalf of the user
- Privilege Escalation: If the receiving service accepts tokens not intended for it, attackers can misuse tokens from one service at another
- ALBEAST-Class Vulnerability: Tokens intended for one tenant/service can be used at another
What to Look For in PRs:
# ❌ CRITICAL SECURITY ISSUE
def call_external_api(user_token):
# Forwarding user's JWT directly to third-party service
response = requests.get(
"https://third-party-api.com/resource",
headers={"Authorization": f"Bearer {user_token}"}
)
Severity: CRITICAL - Must be blocked before merge
2. Validate Audience Claims
Every service that accepts JWTs MUST validate the aud claim matches its own identifier.
RFC Requirements:
- Per RFC 7519: "Each principal intended to process the JWT MUST identify itself with a value in the audience claim"
- Per RFC 9068: "The resource server MUST validate that the aud claim contains a resource indicator value corresponding to an identifier the resource server expects for itself"
- The JWT MUST be rejected if the audience does not match
What to Look For in PRs:
# ❌ MISSING VALIDATION
def verify_token(token):
decoded = jwt.decode(token, public_key, algorithms=['RS256'])
# Missing audience validation!
return decoded
# ✅ CORRECT VALIDATION
def verify_token(token):
decoded = jwt.decode(
token,
public_key,
algorithms=['RS256'],
audience='https://api.myservice.com' # Validates aud claim
)
return decoded
Severity: CRITICAL if missing, HIGH if incomplete
3. Validate All Critical Claims
Beyond audience, validate:
Required Validations:
iss(issuer): Verify the token came from a trusted authorization serveraud(audience): Verify the token is intended for this serviceexp(expiration): Reject expired tokensnbf(not before): Reject tokens used before their valid timealg(algorithm): Prevent algorithm confusion attacks
What to Look For in PRs:
# ❌ VULNERABLE TO ALGORITHM CONFUSION
def verify_token(token):
# Accepts ANY algorithm from the token header
decoded = jwt.decode(token, secret, algorithms=None)
# ✅ CORRECT - EXPLICIT ALGORITHM
def verify_token(token):
# Only accepts expected algorithm
decoded = jwt.decode(
token,
public_key,
algorithms=['RS256'], # Explicit, not from token
issuer='https://auth.example.com',
audience='https://api.example.com'
)
Severity: CRITICAL for algorithm validation, HIGH for other claims
4. Use Token Exchange for Service-to-Service Communication
When a service needs to call another service on behalf of a user, use OAuth Token Exchange (RFC 8693) rather than forwarding the original token.
The Correct Pattern:
# ✅ SECURE TOKEN EXCHANGE PATTERN
def call_downstream_service(user_token):
# Exchange user token for service-specific token
exchange_response = requests.post(
'https://auth.example.com/token',
data={
'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
'subject_token': user_token,
'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
'resource': 'https://downstream-service.example.com',
'scope': 'read:data' # Downscoped to minimum needed
}
)
downstream_token = exchange_response.json()['access_token']
# Use the service-specific token
response = requests.get(
'https://downstream-service.example.com/api/resource',
headers={'Authorization': f'Bearer {downstream_token}'}
)
Benefits of Token Exchange:
- Correct Audience: New token has proper
audclaim for downstream service - Least Privilege: Token is downscoped to minimum permissions needed
- Audit Trail: Clear chain of delegation (user → service A → service B)
- Prevents Confused Deputy: Downstream service can validate the token was properly issued
What to Look For in PRs:
- Services forwarding user JWTs to other services without exchange
- Missing token exchange implementation where multi-service calls exist
- Tokens with overly broad scopes being passed between services
Severity: CRITICAL in security-sensitive contexts, HIGH otherwise
5. Apply Principle of Least Privilege
Scope Downscoping: When exchanging tokens, request only the minimum scopes needed for the specific operation.
# ❌ OVER-PRIVILEGED
exchange_data = {
'scope': 'read write admin delete' # Too many permissions!
}
# ✅ LEAST PRIVILEGE
exchange_data = {
'scope': 'read:invoices' # Only what's needed
}
Severity: MEDIUM to HIGH depending on the over-privileging
6. Secure Token Storage and Transmission
Transport Security:
- JWTs MUST be transmitted over HTTPS only
- Never log full JWTs (mask them if needed for debugging)
- Never include JWTs in URLs (query parameters, path segments)
Storage Security:
- Don't store JWTs in localStorage if they contain sensitive data (XSS risk)
- Consider httpOnly cookies for web applications
- Use secure, encrypted storage on mobile platforms
- Implement token refresh to minimize long-lived token exposure
What to Look For in PRs:
// ❌ SECURITY ISSUES
localStorage.setItem('token', jwt); // XSS vulnerability
console.log('Token:', jwt); // Logging sensitive data
const url = `/api/resource?token=${jwt}`; // Token in URL
// ✅ BETTER PRACTICES
// Use httpOnly cookie or secure storage
document.cookie = `token=${jwt}; Secure; HttpOnly; SameSite=Strict`;
console.log('Token:', jwt.substring(0, 10) + '...'); // Masked logging
Severity: HIGH for URL exposure, MEDIUM for storage issues
Algorithm Confusion Attacks
The Vulnerability
JWT headers include an alg parameter that specifies the signing algorithm. If the verification code doesn't enforce the expected algorithm, attackers can:
- Change RS256 (RSA) to HS256 (HMAC)
- Use the RSA public key as the HMAC secret
- Create validly-signed tokens
What to Look For in PRs:
# ❌ VULNERABLE
def verify_token(token):
# Algorithm taken from token header - DANGEROUS!
header = jwt.get_unverified_header(token)
alg = header['alg']
decoded = jwt.decode(token, key, algorithms=[alg])
# ✅ SECURE
def verify_token(token):
# Algorithm explicitly specified and validated
decoded = jwt.decode(
token,
public_key,
algorithms=['RS256'] # Only RS256 accepted
)
Severity: CRITICAL
None Algorithm Attack
Some JWT libraries accept "alg": "none" which bypasses signature verification entirely.
What to Look For in PRs:
# ❌ VULNERABLE
jwt.decode(token, verify=False) # Dangerous!
jwt.decode(token, algorithms=['RS256', 'none']) # Allows none!
# ✅ SECURE
jwt.decode(token, public_key, algorithms=['RS256'])
Severity: CRITICAL
Common JWT Anti-Patterns
1. Treating ID Tokens as Access Tokens
Problem: ID tokens (from OpenID Connect) are meant for the client that requested authentication, NOT for API authorization.
# ❌ WRONG
# Forwarding ID token to API
api_call(headers={'Authorization': f'Bearer {id_token}'})
# ✅ CORRECT
# Use access token for API calls
api_call(headers={'Authorization': f'Bearer {access_token}'})
Severity: HIGH
2. Missing Signature Validation
Problem: Accepting unsigned JWTs or skipping validation.
# ❌ VULNERABLE
payload = jwt.decode(token, options={"verify_signature": False})
# ✅ SECURE
payload = jwt.decode(token, public_key, algorithms=['RS256'])
Severity: CRITICAL
3. Trusting Token Content Without Validation
Problem: Extracting claims before validating signature and claims.
# ❌ DANGEROUS
def get_user_id(token):
# Decodes without validation!
payload = jwt.decode(token, options={"verify_signature": False})
return payload['user_id']
# ✅ SAFE
def get_user_id(token):
# Validates first, then extracts
payload = jwt.decode(
token,
public_key,
algorithms=['RS256'],
audience='https://api.example.com',
issuer='https://auth.example.com'
)
return payload['user_id']
Severity: CRITICAL
4. Using Weak Secrets for HS256
Problem: Using predictable or weak secrets for HMAC signing.
# ❌ WEAK SECRET
secret = "secret123"
jwt.encode(payload, secret, algorithm='HS256')
# ✅ STRONG SECRET
# Use cryptographically random, high-entropy secret
# At least 256 bits (32 bytes) for HS256
import secrets
secret = secrets.token_bytes(32)
Severity: CRITICAL
5. Overly Broad Audiences
Problem: Using wildcard or overly generic audience values.
# ❌ TOO BROAD
token_data = {
'aud': '*', # Accepts anywhere!
'aud': 'https://example.com' # Too generic
}
# ✅ SPECIFIC
token_data = {
'aud': 'https://api.example.com/v1/orders' # Specific service
}
Severity: MEDIUM to HIGH
Review Checklist for JWT Code
When reviewing PRs involving JWT authentication/authorization:
Critical Checks
- Audience validation: Every service validates
audclaim matches its identifier - Algorithm enforcement: Explicit algorithm list, no
none, no user-controlled algorithm - Signature validation: All tokens are cryptographically validated
- Issuer validation:
issclaim validated against trusted issuers - No token forwarding: Tokens are not forwarded to services not in their
audclaim
High Priority Checks
- Token exchange: Service-to-service calls use token exchange, not forwarding
- Expiration validation:
expclaim checked and enforced - Scope downscoping: Exchanged tokens request minimum necessary scopes
- ID token misuse: ID tokens not used for API authorization
- Transport security: Tokens only transmitted over HTTPS
Medium Priority Checks
- Least privilege: Scopes are specific and minimal
- Secure storage: Tokens stored securely (not localStorage for sensitive data)
- Token refresh: Short-lived tokens with refresh mechanism
- Logging safety: Tokens not logged in full
- Error messages: Don't leak token information in errors
Code Pattern Recognition
Token Forwarding Pattern (Usually Wrong):
# Server receives user_token from client
# Server forwards user_token to another service
downstream_service.call(authorization=user_token) # ❌ Check this!
Token Exchange Pattern (Usually Correct):
# Server receives user_token from client
# Server exchanges for service-specific token
new_token = auth_server.exchange_token(user_token, target_service)
# Server uses new token
downstream_service.call(authorization=new_token) # ✅ Good!
Key RFCs and Standards
- RFC 7519: JSON Web Token (JWT) - Core standard
- RFC 8693: OAuth 2.0 Token Exchange - Service-to-service delegation
- RFC 8707: Resource Indicators for OAuth 2.0 - Explicit audience specification
- RFC 9068: JWT Profile for OAuth 2.0 Access Tokens - Access token best practices
- RFC 9700: OAuth 2.0 Security Best Current Practice (January 2025) - Latest security guidance
When to Escalate
Escalate to security team or mark as CRITICAL if you find:
- Tokens forwarded to services not in their audience
- Missing signature validation
- Algorithm confusion vulnerabilities (accepting
noneor user-controlled algorithm) - Tokens containing sensitive data logged or exposed in URLs
- No audience or issuer validation
- Token exchange not used in multi-service architectures