Loading content...
If OAuth 2.0 flows are the mechanisms for establishing trust, then tokens are the currency of that trust. Every authorized API call, every protected resource request, every microservice interaction in a modern system is governed by tokens. Understanding tokens—their structure, lifecycle, security properties, and management patterns—is essential knowledge for any engineer building production systems.
Tokens seem simple on the surface: they're strings you attach to requests. But beneath this simplicity lies a rich architecture with profound security implications. Poor token management is behind countless security breaches: leaked access tokens enabling data exfiltration, improperly stored refresh tokens allowing persistent unauthorized access, and misconfigured token lifetimes creating either security vulnerabilities or terrible user experiences.
This page equips you with the deep knowledge needed to design and implement token management systems that are both secure and user-friendly—the combination that separates production-grade implementations from security incidents waiting to happen.
By the end of this page, you will understand the fundamental distinction between access tokens and refresh tokens, master token lifecycle management including rotation strategies, learn secure storage patterns for different application types, and be able to design token architectures that balance security with user experience.
An access token is a credential representing authorization granted by the resource owner to the client application. When a client presents an access token to a resource server, it's essentially saying: "The user has authorized me to perform these specific actions on their behalf."
The Bearer Token Model:
Most OAuth 2.0 implementations use bearer tokens—tokens that grant access to whoever possesses them, similar to a movie ticket. This simplicity is both a strength (easy implementation) and a weakness (if stolen, fully usable by attackers).
GET /api/users/me HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
The resource server validates the token and, if valid, processes the request according to the scopes and claims within the token.
| Characteristic | Description | Implications |
|---|---|---|
| Purpose | Authorize access to protected resources | Used in every API call—high frequency usage |
| Lifetime | Typically short (15 min to 1 hour) | Limits exposure window if compromised |
| Format | Often JWT or opaque string | JWTs enable local validation; opaque requires introspection |
| Scopes | Defines what operations are permitted | Principle of least privilege—request only needed scopes |
| Revocability | Challenging for JWTs; easy for opaque | Tradeoff between performance and control |
| Transmission | Sent with every request (Authorization header) | Must be transmitted securely (HTTPS only) |
Opaque tokens prioritize control and revocability but require communication with the authorization server for every validation. JWTs prioritize performance and decentralization but sacrifice instant revocation. Many production systems use JWTs with short lifetimes and maintain deny lists for compromised tokens—a hybrid approach.
A refresh token is a credential used to obtain new access tokens without requiring the user to re-authenticate. While access tokens are short-lived and frequently used, refresh tokens are long-lived and used sparingly—only when access tokens expire.
Why Refresh Tokens Exist:
Imagine if every access token lasted a week. If compromised, an attacker has a week of unauthorized access. Now imagine access tokens lasting 15 minutes but requiring user login every 15 minutes—terrible usability.
Refresh tokens solve this by decoupling token lifetime from session lifetime:
| Aspect | Access Token | Refresh Token |
|---|---|---|
| Purpose | Authorize API requests | Obtain new access tokens |
| Typical Lifetime | 15 minutes to 1 hour | 7 days to 90 days |
| Usage Frequency | Every API call | Only when access token expires |
| Where Sent | Resource Server (API) | Authorization Server only |
| Exposure Risk | Higher (frequent transmission) | Lower (rare transmission) |
| If Compromised | Limited damage (short validity) | Serious damage (long-term access) |
| Storage Priority | Accessible but secure | Maximum security required |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
// Robust Token Refresh Patterninterface TokenSet { accessToken: string; refreshToken: string; expiresAt: number; // Unix timestamp} class TokenManager { private tokens: TokenSet | null = null; private refreshPromise: Promise<TokenSet> | null = null; constructor( private tokenEndpoint: string, private clientId: string, ) {} // Get valid access token, refreshing if needed async getAccessToken(): Promise<string> { if (!this.tokens) { throw new Error('Not authenticated'); } // Check if token expires within buffer period (60 seconds) const bufferMs = 60 * 1000; const isExpiringSoon = Date.now() > (this.tokens.expiresAt - bufferMs); if (isExpiringSoon) { await this.refreshTokens(); } return this.tokens.accessToken; } // Refresh tokens with concurrency handling private async refreshTokens(): Promise<void> { // Prevent multiple concurrent refresh requests if (this.refreshPromise) { await this.refreshPromise; return; } this.refreshPromise = this.executeRefresh(); try { this.tokens = await this.refreshPromise; } finally { this.refreshPromise = null; } } private async executeRefresh(): Promise<TokenSet> { const response = await fetch(this.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: this.tokens!.refreshToken, client_id: this.clientId, }), }); if (!response.ok) { // Refresh failed - likely revoked or expired this.tokens = null; throw new Error('Session expired - please log in again'); } const data = await response.json(); return { accessToken: data.access_token, // Use new refresh token if provided (rotation) refreshToken: data.refresh_token || this.tokens!.refreshToken, expiresAt: Date.now() + (data.expires_in * 1000), }; } // Authenticated fetch wrapper async authenticatedFetch(url: string, options: RequestInit = {}) { const token = await this.getAccessToken(); const response = await fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${token}`, }, }); // Handle 401 by refreshing and retrying once if (response.status === 401) { await this.refreshTokens(); const newToken = await this.getAccessToken(); return fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${newToken}`, }, }); } return response; }}A compromised refresh token is essentially a compromised account. Unlike access tokens that expire quickly, a stolen refresh token can allow attackers to maintain persistent access for weeks or months. Refresh tokens require the highest level of storage security and should be rotated on every use.
Refresh Token Rotation is a critical security practice where each time a refresh token is used, it's invalidated and replaced with a new one. This limits the damage from token theft and enables detection of token replay attacks.
How Rotation Works:
Why Rotation Matters:
Without rotation, a stolen refresh token could be used indefinitely (until it naturally expires). With rotation, each token is single-use. If an attacker steals R1 and uses it, the legitimate client's next refresh with R1 will fail (already used), alerting to the compromise.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
// Server-side Refresh Token Rotation Implementationinterface RefreshTokenRecord { tokenHash: string; userId: string; clientId: string; scope: string; familyId: string; // Links related tokens for revocation createdAt: Date; expiresAt: Date; usedAt: Date | null; replacedByHash: string | null;} class RefreshTokenService { constructor(private db: Database, private crypto: CryptoService) {} // Issue new refresh token async issueRefreshToken( userId: string, clientId: string, scope: string, familyId?: string, // New family for initial login ): Promise<string> { const token = this.crypto.generateSecureToken(256); const tokenHash = await this.crypto.hashToken(token); await this.db.refreshTokens.create({ tokenHash, userId, clientId, scope, familyId: familyId || this.crypto.generateUUID(), createdAt: new Date(), expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days usedAt: null, replacedByHash: null, }); return token; } // Rotate refresh token (called during token refresh) async rotateRefreshToken(oldToken: string): Promise<{ newToken: string; accessToken: string; userId: string; }> { const oldTokenHash = await this.crypto.hashToken(oldToken); const record = await this.db.refreshTokens.findUnique({ where: { tokenHash: oldTokenHash }, }); // Token doesn't exist if (!record) { throw new TokenError('invalid_grant', 'Refresh token not found'); } // Token expired if (record.expiresAt < new Date()) { await this.revokeTokenFamily(record.familyId); throw new TokenError('invalid_grant', 'Refresh token expired'); } // CRITICAL: Detect token reuse (already used = potential theft) if (record.usedAt !== null) { // This token was already used! Revoke ENTIRE family // Either the token was stolen, or the legitimate client is out of sync await this.revokeTokenFamily(record.familyId); // Log security event for investigation await this.logSecurityEvent('REFRESH_TOKEN_REUSE', { userId: record.userId, clientId: record.clientId, familyId: record.familyId, originalUseTime: record.usedAt, reuseAttemptTime: new Date(), }); throw new TokenError( 'invalid_grant', 'Token reuse detected - session terminated for security' ); } // Issue new refresh token in same family const newToken = await this.issueRefreshToken( record.userId, record.clientId, record.scope, record.familyId, // Same family ); const newTokenHash = await this.crypto.hashToken(newToken); // Mark old token as used (not deleted - kept for reuse detection) await this.db.refreshTokens.update({ where: { tokenHash: oldTokenHash }, data: { usedAt: new Date(), replacedByHash: newTokenHash, }, }); // Issue new access token const accessToken = await this.issueAccessToken( record.userId, record.clientId, record.scope, ); return { newToken, accessToken, userId: record.userId }; } // Revoke all tokens in a family (used for logout or breach detection) async revokeTokenFamily(familyId: string): Promise<void> { await this.db.refreshTokens.updateMany({ where: { familyId }, data: { expiresAt: new Date() }, // Immediately expire }); } // Revoke all tokens for a user (password change, account compromise) async revokeAllUserTokens(userId: string): Promise<void> { await this.db.refreshTokens.updateMany({ where: { userId }, data: { expiresAt: new Date() }, }); }}By linking tokens in families via familyId, you can detect when both an attacker and legitimate user try to use tokens. When the second use is detected, revoking the entire family ensures neither the attacker nor (regrettably) the legitimate user retains access—forcing re-authentication where the legitimate user can reclaim their account.
Choosing appropriate token lifetimes is a critical balancing act between security and usability. Tokens that live too long create extended vulnerability windows; tokens that expire too quickly frustrate users with constant re-authentication.
Factors Influencing Token Lifetime:
| Scenario | Access Token | Refresh Token | Rationale |
|---|---|---|---|
| Banking/Financial | 5-15 minutes | 8 hours (or session) | High sensitivity; minimize exposure window |
| Enterprise SaaS | 15-30 minutes | 7-14 days | Balance security with productivity |
| Consumer Mobile App | 30-60 minutes | 30-90 days | Prioritize convenience; users hate re-login |
| IoT/Embedded Devices | 1-4 hours | 1 year | Devices often can't re-authenticate interactively |
| M2M/Service Accounts | 30-60 minutes | Not applicable (*) | No refresh; use client credentials when needed |
| High-Security Admin | 5 minutes | 1 hour | Privileged access requires strictest controls |
Sliding Window vs Absolute Expiration:
Most production systems use absolute expiration for the refresh token (forcing periodic full re-authentication) combined with short access tokens (limiting exposure). Some add session inactivity timeouts—if no activity for N days, invalidate the refresh token.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// Configurable Token Lifetime Policyinterface TokenLifetimePolicy { accessTokenTTL: number; // seconds refreshTokenTTL: number; // seconds absoluteSessionTTL: number; // seconds - max total session length inactivityTimeout: number; // seconds - revoke if no activity rotateRefreshTokens: boolean; requireReauthForSensitive: boolean;} const policies: Record<string, TokenLifetimePolicy> = { consumer: { accessTokenTTL: 60 * 60, // 1 hour refreshTokenTTL: 90 * 24 * 60 * 60, // 90 days absoluteSessionTTL: 365 * 24 * 60 * 60, // 1 year inactivityTimeout: 30 * 24 * 60 * 60, // 30 days rotateRefreshTokens: true, requireReauthForSensitive: true, }, enterprise: { accessTokenTTL: 30 * 60, // 30 minutes refreshTokenTTL: 14 * 24 * 60 * 60, // 14 days absoluteSessionTTL: 30 * 24 * 60 * 60, // 30 days inactivityTimeout: 7 * 24 * 60 * 60, // 7 days rotateRefreshTokens: true, requireReauthForSensitive: true, }, banking: { accessTokenTTL: 10 * 60, // 10 minutes refreshTokenTTL: 8 * 60 * 60, // 8 hours (workday) absoluteSessionTTL: 24 * 60 * 60, // 24 hours inactivityTimeout: 15 * 60, // 15 minutes rotateRefreshTokens: true, requireReauthForSensitive: true, },}; class TokenLifetimeManager { constructor(private policy: TokenLifetimePolicy) {} calculateAccessTokenExpiry(): Date { return new Date(Date.now() + this.policy.accessTokenTTL * 1000); } calculateRefreshTokenExpiry(sessionStartTime: Date): Date { const normalExpiry = Date.now() + this.policy.refreshTokenTTL * 1000; const absoluteLimit = sessionStartTime.getTime() + this.policy.absoluteSessionTTL * 1000; // Refresh token can't extend beyond absolute session limit return new Date(Math.min(normalExpiry, absoluteLimit)); } shouldRevokeForInactivity(lastActivityTime: Date): boolean { const inactiveMs = Date.now() - lastActivityTime.getTime(); return inactiveMs > this.policy.inactivityTimeout * 1000; } shouldRequireReauth( operation: string, lastAuthTime: Date, sensitiveOps: string[] ): boolean { if (!this.policy.requireReauthForSensitive) return false; if (!sensitiveOps.includes(operation)) return false; // Require re-auth if last auth was more than 15 min ago const reauthThreshold = 15 * 60 * 1000; return Date.now() - lastAuthTime.getTime() > reauthThreshold; }}Even with valid tokens, sensitive operations (changing password, transferring funds, modifying security settings) should require recent authentication. This 'step-up' or 're-auth' pattern ensures that even if tokens are compromised, attackers can't perform high-impact actions without proving identity again.
How and where you store tokens fundamentally determines your application's security posture. Different client types have different storage options, each with distinct security tradeoffs.
The Core Challenge:
Tokens must be stored somewhere accessible for making authenticated requests, but attackers are actively looking for token storage locations. The storage mechanism must protect against:
| Client Type | Storage Option | Security Level | Considerations |
|---|---|---|---|
| Server-side Web App | Server session/encrypted cookies | High | Tokens never reach browser; server manages all |
| SPA (Best) | Backend-for-Frontend (BFF) with httpOnly cookies | High | BFF holds tokens; SPA uses session cookies |
| SPA (Acceptable) | In-memory (JavaScript variables) | Medium | Lost on refresh; requires silent refresh on load |
| SPA (Avoid) | localStorage/sessionStorage | Low | Vulnerable to XSS; avoid for sensitive apps |
| Mobile Native | Keychain (iOS) / Keystore (Android) | High | Hardware-backed secure storage; best option |
| Mobile (Avoid) | SharedPreferences / UserDefaults | Low | Accessible to rooted/jailbroken devices |
| Desktop App | OS credential manager / encrypted file | Medium-High | Electron: use safeStorage; Native: use OS keychain |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// Pattern 1: Backend-for-Frontend (BFF) - Recommended for SPAs// The BFF is a server-side component that holds tokens and proxies API requests // BFF Server (Express/Node.js)const express = require('express');const session = require('express-session');const RedisStore = require('connect-redis').default; const app = express(); // Session configuration with httpOnly cookiesapp.use(session({ store: new RedisStore({ client: redisClient }), secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { httpOnly: true, // Prevents XSS access to cookie secure: true, // HTTPS only sameSite: 'strict', // CSRF protection maxAge: 24 * 60 * 60 * 1000, // 24 hours },})); // BFF token storage in sessionapp.post('/bff/auth/callback', async (req, res) => { const { code } = req.body; // Exchange code for tokens (server-side, secure) const tokens = await exchangeCodeForTokens(code); // Store tokens in server session - NEVER sent to browser req.session.tokens = { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: Date.now() + tokens.expires_in * 1000, }; res.json({ success: true }); // Only success indicator to frontend}); // BFF API proxy - adds token to requestsapp.all('/bff/api/*', async (req, res) => { if (!req.session.tokens) { return res.status(401).json({ error: 'Not authenticated' }); } // Refresh if needed if (Date.now() > req.session.tokens.expiresAt - 60000) { req.session.tokens = await refreshTokens(req.session.tokens); } // Proxy request to actual API with token const apiPath = req.path.replace('/bff/api', ''); const response = await fetch(`https://api.example.com${apiPath}`, { method: req.method, headers: { 'Authorization': `Bearer ${req.session.tokens.accessToken}`, 'Content-Type': req.headers['content-type'], }, body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body), }); res.status(response.status).json(await response.json());});localStorage is accessible to any JavaScript running on your domain. A single XSS vulnerability—in your code or any third-party script—can exfiltrate all stored tokens. For apps handling sensitive data, use the BFF pattern or in-memory storage with silent refresh.
Token revocation—the ability to invalidate tokens before their natural expiration—is essential for security operations like logout, password changes, and responding to suspected compromises. Unfortunately, it's more complex than it seems, especially with self-contained JWTs.
The JWT Revocation Challenge:
JWTs are designed to be validated locally without contacting the authorization server. This is great for performance but terrible for revocation—if the JWT hasn't expired and has a valid signature, it passes validation even if the user logged out or the token was revoked.
Revocation Strategies:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
// Multi-Strategy Token Revocation Systeminterface RevocationConfig { enableDenyList: boolean; enableTokenVersioning: boolean; denyListTTL: number; // How long to keep revoked tokens in list} class TokenRevocationService { constructor( private redis: Redis, private db: Database, private config: RevocationConfig, ) {} // Revoke a specific token (e.g., on logout) async revokeToken(tokenId: string, expiresAt: Date): Promise<void> { if (!this.config.enableDenyList) return; // Only need to deny-list until token would naturally expire const ttlMs = expiresAt.getTime() - Date.now(); if (ttlMs <= 0) return; // Already expired // Add to Redis deny list with automatic expiration await this.redis.set( `revoked:${tokenId}`, '1', 'PX', Math.min(ttlMs, this.config.denyListTTL * 1000), ); } // Revoke all tokens for a user (e.g., password change, security concern) async revokeAllUserTokens(userId: string): Promise<void> { // Strategy 1: Revoke all refresh tokens in database await this.db.refreshTokens.updateMany({ where: { userId }, data: { revokedAt: new Date() }, }); // Strategy 2: Increment user's token version if (this.config.enableTokenVersioning) { await this.db.users.update({ where: { id: userId }, data: { tokenVersion: { increment: 1 } }, }); } // Strategy 3: Publish revocation event for distributed systems await this.publishRevocationEvent({ type: 'USER_TOKENS_REVOKED', userId, timestamp: new Date(), }); } // Check if token is revoked (called during validation) async isTokenRevoked(tokenId: string, userId: string, tokenVersion?: number): Promise<boolean> { // Check deny list if (this.config.enableDenyList) { const revoked = await this.redis.get(`revoked:${tokenId}`); if (revoked) return true; } // Check token version if (this.config.enableTokenVersioning && tokenVersion !== undefined) { const user = await this.db.users.findUnique({ where: { id: userId }, select: { tokenVersion: true }, }); if (user && user.tokenVersion > tokenVersion) { return true; // Token from older generation } } return false; } // Publish revocation event for distributed systems private async publishRevocationEvent(event: RevocationEvent): Promise<void> { await this.redis.publish('token-revocation', JSON.stringify(event)); }} // Token Validator incorporating revocation checksclass TokenValidator { constructor( private jwtVerifier: JWTVerifier, private revocationService: TokenRevocationService, ) {} async validateAccessToken(token: string): Promise<TokenPayload> { // Step 1: Verify JWT signature and expiration const payload = await this.jwtVerifier.verify(token); // Step 2: Check revocation const isRevoked = await this.revocationService.isTokenRevoked( payload.jti, // Token ID payload.sub, // User ID payload.version, // Token version (if present) ); if (isRevoked) { throw new TokenError('invalid_token', 'Token has been revoked'); } return payload; }}Production systems often combine strategies: short access token lifetimes (primary revocation mechanism), refresh token rotation (detect theft), and a deny list for immediate critical revocations (compromised credentials). The deny list only needs to cover the access token lifetime—typically just minutes of entries.
Tokens are useless if they're intercepted in transit. Secure token transmission is as important as secure storage. The standard approach uses the HTTP Authorization header with the Bearer scheme, but there are nuances and alternatives.
The Authorization Header:
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
This is the standard mechanism defined by RFC 6750. It's widely supported, doesn't pollute URLs, and can be easily stripped by reverse proxies before logging.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// Secure API Client with Token Transmission Best Practicesclass SecureApiClient { private baseUrl: string; private tokenManager: TokenManager; constructor(baseUrl: string, tokenManager: TokenManager) { // Enforce HTTPS if (!baseUrl.startsWith('https://')) { throw new Error('API base URL must use HTTPS'); } this.baseUrl = baseUrl; this.tokenManager = tokenManager; } async request<T>( method: string, path: string, options: RequestOptions = {} ): Promise<T> { const token = await this.tokenManager.getAccessToken(); const headers: Record<string, string> = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', // Additional security headers 'X-Request-ID': crypto.randomUUID(), // For correlation }; // NEVER put token in URL const url = new URL(path, this.baseUrl); const response = await fetch(url.toString(), { method, headers, body: options.body ? JSON.stringify(options.body) : undefined, credentials: 'omit', // Don't send cookies to API }); if (response.status === 401) { // Token might be expired or revoked await this.tokenManager.refresh(); return this.request(method, path, options); } if (!response.ok) { throw new ApiError(response.status, await response.text()); } return response.json(); }} // Express middleware to redact tokens from logsimport morgan from 'morgan'; // Custom morgan token that redacts Authorization headermorgan.token('headers-safe', (req) => { const headers = { ...req.headers }; if (headers.authorization) { // Keep token type, redact actual token headers.authorization = headers.authorization.replace( /^(Bearer )(.+)$/, '$1[REDACTED]' ); } return JSON.stringify(headers);}); app.use(morgan(':method :url :status - :headers-safe'));Tokens commonly leak through error messages, logs, and stack traces. Ensure your error handling never includes the full token. Log only the first/last few characters for debugging: 'Token eyJ...Xtw failed validation' is safer than including the full token.
Token management is where OAuth 2.0 theory meets production reality. We've covered the critical knowledge needed to build secure, user-friendly token systems:
What's Next:
We've mastered OAuth 2.0 flows and token management. But OAuth handles authorization, not authentication—it tells you what someone can access, not who they are. The next page introduces OpenID Connect, the identity layer built on OAuth 2.0 that provides standardized authentication and user information.
You now have deep expertise in access tokens and refresh tokens—their purpose, lifecycle, security properties, and production management patterns. You can design token systems that are both secure and user-friendly, implement proper rotation and revocation, and choose appropriate storage for any client type. Next, we explore OpenID Connect for identity.