OAuth2 token management is deceptively complex. While the OAuth2 specification provides a robust framework for authorization, the devil is in the implementation details. A single misconfigured token lifetime, missing refresh token rotation, or improperly stored access token can expose your entire API to compromise.
This guide covers the complete lifecycle of OAuth2 tokens—from initial grant to refresh token rotation to revocation—with security best practices that protect against modern threats.
Understanding OAuth2 Flows
OAuth2 defines several authorization flows (also called grant types), each designed for specific use cases. Choosing the wrong flow is the first mistake most developers make.
1. Authorization Code Flow (Most Secure)
The authorization code flow is the gold standard for server-side applications. It provides the highest security by never exposing access tokens to the browser.
# Step 1: Redirect user to authorization server
https://auth.example.com/oauth/authorize?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/callback&
scope=read:data write:data&
state=random_state_string
# Step 2: Authorization server redirects back with code
https://yourapp.com/callback?
code=AUTHORIZATION_CODE&
state=random_state_string
# Step 3: Exchange code for tokens (server-to-server)
POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTHORIZATION_CODE&
redirect_uri=https://yourapp.com/callback&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET
# Response
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600
}
Use authorization code flow for any server-side application where you can securely store a client secret. This includes traditional web apps, backend services, and mobile apps using secure storage.
2. Authorization Code Flow with PKCE
PKCE (Proof Key for Code Exchange) enhances the authorization code flow for public clients that can't securely store secrets—like single-page applications and mobile apps.
# Step 1: Generate code verifier and challenge
code_verifier = base64url(random(32)) # Store this
code_challenge = base64url(SHA256(code_verifier))
# Step 2: Authorization request includes challenge
https://auth.example.com/oauth/authorize?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/callback&
scope=read:data&
state=random_state&
code_challenge=CODE_CHALLENGE&
code_challenge_method=S256
# Step 3: Token exchange includes verifier
POST https://auth.example.com/oauth/token
{
"grant_type": "authorization_code",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "https://yourapp.com/callback",
"client_id": "YOUR_CLIENT_ID",
"code_verifier": "CODE_VERIFIER"
}
PKCE prevents authorization code interception attacks by cryptographically binding the token exchange to the original authorization request.
3. Client Credentials Flow
The client credentials flow is for server-to-server authentication where no user is involved.
POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
scope=api:access
# Response
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 7200
}
Never use client credentials flow in frontend applications. The client secret would be exposed in browser code, allowing anyone to impersonate your application.
4. Refresh Token Flow
Refresh tokens allow obtaining new access tokens without re-authenticating the user. This is critical for long-lived sessions.
POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&
refresh_token=REFRESH_TOKEN&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET
# Response
{
"access_token": "NEW_ACCESS_TOKEN",
"refresh_token": "NEW_REFRESH_TOKEN", // Token rotation
"token_type": "Bearer",
"expires_in": 3600
}
Token Lifecycle Management
Proper token lifecycle management is where most security vulnerabilities occur. Here's how to handle tokens from creation to expiration.
Access Token Lifetimes
Access tokens should be short-lived. The industry standard in 2026 is:
- High-security applications: 5-15 minutes
- Standard applications: 15-60 minutes
- Internal services: 1-4 hours maximum
Shorter lifetimes limit the damage if a token is compromised. The inconvenience is offset by refresh tokens for seamless token renewal.
Refresh Token Lifetimes
Refresh tokens can be longer-lived but should still expire:
- Consumer applications: 30-90 days
- Enterprise applications: 7-30 days
- High-security applications: 1-7 days with rotation
Refresh Token Rotation
Refresh token rotation is a critical security mechanism where each refresh token is single-use. When you exchange a refresh token for a new access token, you also receive a new refresh token.
// First refresh
POST /oauth/token
{ "grant_type": "refresh_token", "refresh_token": "RT_v1" }
Response:
{ "access_token": "AT_v2", "refresh_token": "RT_v2" } // New refresh token!
// Second refresh - RT_v1 is now invalid
POST /oauth/token
{ "grant_type": "refresh_token", "refresh_token": "RT_v2" }
Response:
{ "access_token": "AT_v3", "refresh_token": "RT_v3" }
// Attempting to reuse RT_v1 triggers security alert
POST /oauth/token
{ "grant_type": "refresh_token", "refresh_token": "RT_v1" }
Response: 401 Unauthorized
Action: Revoke entire token family (all RT_v1, RT_v2, RT_v3)
Refresh token rotation detects token theft. If an attacker steals a refresh token and uses it, the legitimate client will also try to use it (or a subsequent token). The authorization server detects this reuse and revokes all tokens in that family.
Token Storage Best Practices
Where and how you store tokens dramatically impacts security.
Server-Side Applications
- Access tokens: Store in memory (server session) with encrypted session cookies
- Refresh tokens: Store in encrypted database, never in session cookies
- Client secrets: Store in environment variables or secret management systems (KnoxCall, HashiCorp Vault)
Single-Page Applications (SPAs)
- Access tokens: Store in memory only (JavaScript variable)
- Never in localStorage: Vulnerable to XSS attacks
- Never in sessionStorage: Still vulnerable to XSS
- Refresh tokens: Use Backend-for-Frontend (BFF) pattern—don't send refresh tokens to the browser
Mobile Applications
- iOS: Use Keychain for token storage
- Android: Use EncryptedSharedPreferences or Android Keystore
- Always use PKCE: Never embed client secrets in mobile apps
Never store OAuth2 tokens in localStorage, sessionStorage, or unencrypted cookies in web applications. These are all vulnerable to XSS attacks. A single XSS vulnerability can compromise all your users' sessions.
Token Revocation
Implementing proper token revocation is essential for security events like user logout, password changes, or detected compromises.
POST https://auth.example.com/oauth/revoke
Content-Type: application/x-www-form-urlencoded
token=ACCESS_OR_REFRESH_TOKEN&
token_type_hint=refresh_token& // Optional hint
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET
Your authorization server should support:
- Individual token revocation: Revoke specific access or refresh tokens
- Family revocation: Revoke all tokens in a refresh token family
- User-level revocation: Revoke all tokens for a specific user
- Client-level revocation: Revoke all tokens for a specific application
Token Validation
Every API request must validate the access token. Proper validation prevents unauthorized access.
// Server-side token validation
async function validateAccessToken(token) {
// 1. Verify signature (for JWTs)
const decoded = await jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'your-api'
});
// 2. Check expiration
if (decoded.exp < Date.now() / 1000) {
throw new Error('Token expired');
}
// 3. Check token hasn't been revoked (check database or cache)
const isRevoked = await redis.get(`revoked:${decoded.jti}`);
if (isRevoked) {
throw new Error('Token revoked');
}
// 4. Validate scopes for this endpoint
const requiredScopes = ['read:data'];
const hasScopes = requiredScopes.every(s => decoded.scope.includes(s));
if (!hasScopes) {
throw new Error('Insufficient permissions');
}
return decoded;
}
JWT vs Opaque Tokens
You have two main options for token format:
- JWT (JSON Web Tokens): Self-contained, cryptographically signed, can be validated without database lookup. Larger size (~1KB), harder to revoke immediately.
- Opaque tokens: Random strings, require database lookup for validation. Smaller size, easy to revoke, but require server round-trip for every validation.
Use JWTs for access tokens (validated frequently, short-lived) and opaque tokens for refresh tokens (validated infrequently, need reliable revocation). This balances performance and security.
Common OAuth2 Security Vulnerabilities
1. Missing State Parameter
The state parameter prevents CSRF attacks on the OAuth2 flow. Always validate it matches on callback.
// Generate and store state before redirect
const state = crypto.randomBytes(32).toString('hex');
await redis.setex(`oauth:state:${state}`, 600, userId);
// Redirect to authorization server
redirect(`https://auth.example.com/authorize?state=${state}&...`);
// Validate on callback
const storedState = await redis.get(`oauth:state:${receivedState}`);
if (!storedState || storedState !== userId) {
throw new Error('Invalid state - possible CSRF attack');
}
2. Open Redirector
Validate that redirect_uri matches registered URIs exactly. Attackers can exploit lax validation to steal authorization codes.
// VULNERABLE
if (redirect_uri.startsWith('https://yourapp.com')) { // Too permissive!
// Attacker can use: https://yourapp.com.evil.com
}
// SECURE
const allowedRedirects = [
'https://yourapp.com/callback',
'https://yourapp.com/oauth/callback'
];
if (!allowedRedirects.includes(redirect_uri)) {
throw new Error('Invalid redirect_uri');
}
3. Insufficient Scope Validation
Always check that the access token has the required scopes for the requested operation.
// Check scopes on every sensitive endpoint
app.delete('/api/users/:id', requireScopes(['delete:users']), async (req, res) => {
// Only reaches here if token has delete:users scope
await deleteUser(req.params.id);
});
How KnoxCall Handles OAuth2 Automatically
Managing OAuth2 flows manually is complex and error-prone. KnoxCall provides built-in OAuth2 handling:
- Automatic token rotation: KnoxCall handles refresh token rotation transparently
- Secure token storage: Encrypted storage with environment-based configuration
- Token lifecycle management: Automatic renewal before expiration
- Multi-provider support: Works with any OAuth2-compliant authorization server
- Audit logging: Complete visibility into token operations
- Scope management: Automatic validation of required scopes
Instead of writing and maintaining complex token management code, simply configure your OAuth2 provider in KnoxCall and let it handle the entire lifecycle.
OAuth2 Implementation Checklist
- ✓ Use authorization code flow with PKCE for web and mobile apps
- ✓ Implement refresh token rotation to detect token theft
- ✓ Keep access tokens short-lived (15 minutes or less)
- ✓ Store tokens securely (never in localStorage for web apps)
- ✓ Validate state parameter to prevent CSRF
- ✓ Validate redirect_uri exactly to prevent authorization code theft
- ✓ Check token scopes on every API request
- ✓ Implement comprehensive logging for security monitoring
- ✓ Support token revocation at user and application levels
- ✓ Use HTTPS everywhere (OAuth2 requires TLS)
Key Takeaways
- OAuth2 provides robust authorization when implemented correctly, but improper token management is a top cause of breaches
- Use authorization code flow with PKCE for maximum security in modern applications
- Refresh token rotation is essential for detecting stolen tokens
- Access tokens should be short-lived (5-15 minutes) to limit exposure
- Never store tokens in localStorage or sessionStorage in web applications
- Validate every aspect: signatures, expiration, revocation status, scopes, state, and redirect URIs