When using JWTs with long expiration times (e.g., 24 hours), a critical security gap emerges during password reset flows:
- User authenticates and receives a JWT valid for 24 hours
- User initiates a password reset (possibly because their account was compromised)
- User successfully resets their password
- Security gap: The old JWT remains valid for up to 24 hours
- An attacker who previously obtained the JWT can still access the account
- Compromised Credentials: If a user resets their password because they suspect unauthorized access, the old JWT undermines the security action
- No Immediate Revocation: Stateless JWTs cannot be invalidated without additional infrastructure
- Extended Attack Window: 24-hour tokens give attackers a full day to exploit stolen tokens
- False Security: Users believe changing their password protects them immediately
Replace single long-lived JWTs with a dual-token system:
-
Access Token (JWT, stateless)
- Short lifespan: 15 minutes
- Contains user identity and permissions
- Used for API authorization
- Cannot be revoked (stateless)
-
Refresh Token (opaque, stateful)
- Long lifespan: 7-30 days
- Stored in secure database with user association
- Used only to obtain new access tokens
- Can be revoked immediately
Before Password Reset:
- User has valid access token (expires in ≤15 min)
- User has valid refresh token (stored in database)
After Password Reset:
- Backend deletes all refresh tokens for that user from database
- Old access token remains valid for ≤15 minutes (acceptable risk window)
- When access token expires, attacker attempts to use refresh token
- Backend rejects refresh token (deleted from database)
- Attacker is permanently locked out
Maximum exposure window reduced from 24 hours to 15 minutes — a 96% reduction in risk.
refresh_tokens table:
- id: UUID (primary key)
- user_id: UUID (foreign key to users)
- token_hash: string (hashed refresh token)
- expires_at: timestamp
- created_at: timestamp
- last_used_at: timestamp (optional, for auditing)
- device_info: string (optional, for user visibility)
- ip_address: string (optional, for security monitoring)
User credentials → Backend
↓
Backend validates credentials
↓
Generate access token (JWT, 15 min expiry)
↓
Generate refresh token (random string, 30 days expiry)
↓
Hash refresh token, store in database
↓
Return BOTH tokens to client
Client sends access token in Authorization header
↓
Backend validates JWT signature + expiry
↓
If valid: Process request
If expired: Return 401 with "token_expired" error
Client sends refresh token to /auth/refresh endpoint
↓
Backend hashes token, queries database
↓
If found AND not expired:
- Generate new access token (JWT, 15 min)
- Optionally rotate refresh token (see rotation strategy)
- Return new token(s)
If not found OR expired:
- Return 401, force re-login
User resets password
↓
Backend updates password hash
↓
DELETE FROM refresh_tokens WHERE user_id = ?
↓
All refresh tokens invalidated
↓
Old access tokens expire naturally within 15 min
Client-Side:
- DO: Store in httpOnly, secure, SameSite cookies (web apps)
- DO: Use secure storage APIs (iOS Keychain, Android Keystore for mobile)
- DON'T: Store in localStorage (vulnerable to XSS)
- DON'T: Store in sessionStorage (same XSS risk)
Server-Side:
- Store only cryptographic hash (e.g., SHA-256 or bcrypt)
- Never store plaintext refresh tokens
- Index by user_id for efficient bulk invalidation
Strategy: Issue a new refresh token with each refresh request
Benefits:
- Limits impact of stolen refresh tokens
- Enables detection of token theft (same token used twice)
- Provides audit trail
Implementation:
/auth/refresh receives refresh token
↓
Validate and consume token (mark as used or delete)
↓
Generate NEW refresh token
↓
Return new access token + new refresh token
Theft Detection: If a consumed refresh token is used again:
- Invalidate all tokens for that user (security breach detected)
- Force re-authentication
- Alert user of suspicious activity
All refresh tokens should be invalidated on:
- Password reset
- Password change
- Email change (if email is authentication identifier)
- User-initiated "log out all devices"
- Account deletion
- Admin-initiated account suspension
- Security role/permission changes (optional, based on security requirements)
1. Store both tokens after login
2. Use access token for all API requests
3. On 401 with "token_expired":
a. Attempt refresh with refresh token
b. If refresh succeeds: Retry original request with new access token
c. If refresh fails: Redirect to login
4. Implement automatic refresh before expiry (proactive refresh at 80% lifetime)
Response Interceptor:
- Detect 401 errors with expired token
- Attempt token refresh
- Retry failed request with new token
- On refresh failure, clear tokens and redirect to login
Request Interceptor:
- Check access token expiry before request
- If expiring soon (e.g., <2 min remaining), refresh proactively
- Prevents mid-request token expiration
Expired Token Removal:
- Schedule daily cleanup job
- Delete refresh tokens where
expires_at < NOW() - Prevents database bloat
Inactive Token Pruning:
- Optional: Remove tokens unused for extended period (e.g., 90 days)
- Helps identify abandoned devices/sessions
User Dashboard Feature:
- List active refresh tokens (by device/location)
- Allow users to revoke individual tokens
- Provide "log out all devices" action
✅ Security: Immediate revocation capability for critical actions
✅ Granular Control: Invalidate specific devices/sessions
✅ Audit Trail: Track token usage patterns
✅ User Transparency: Users can see active sessions
✅ Reduced Risk Window: 15 min vs 24 hours (96% reduction)
❌ Complexity: More complex than single-token approach
❌ Database Dependency: Refresh endpoint requires database lookup
❌ Storage Overhead: Must store refresh tokens per user/device
❌ Network Overhead: Additional refresh requests
❌ State Management: No longer purely stateless
Caching Strategy:
- Cache refresh token hashes in Redis
- TTL matches token expiry
- On invalidation (password reset), clear cache + database
- Reduces database load for high-traffic apps
Database Indexing:
INDEX on (user_id, expires_at) - for cleanup queries
INDEX on (token_hash) - for validation queries
INDEX on (user_id, created_at) - for session listing
Approach: Maintain database of revoked tokens
Why Rejected:
- Must store every issued token (enormous database)
- Every API request requires database lookup (performance hit)
- Loses stateless JWT benefit entirely
- Database grows linearly with token issuance rate
Approach: Use 15-min JWT without refresh tokens
Why Rejected:
- Forces re-login every 15 minutes (terrible UX)
- Mobile apps would require constant re-authentication
- Users wouldn't stay logged in during active sessions
Approach: Traditional server-side sessions
Why Rejected:
- Difficult to scale horizontally
- No native mobile/SPA support
- Sticky sessions required for load balancing
- Doesn't leverage JWT benefits (payload claims, offline validation)
- Create refresh_tokens table
- Build /auth/refresh endpoint
- Implement token rotation logic
- Modify login to return both tokens
- Update client to store both tokens
- Maintain backward compatibility (still issue long-lived JWT)
- Roll out client updates with automatic refresh
- Monitor error rates
- Gradual rollout by user segment
- Gradually reduce from 24h → 4h → 1h → 15min
- Monitor for refresh failures
- Provide user communication about "more secure sessions"
- Remove long-lived token support
- All new tokens are 15-min + refresh
- Login returns both tokens with correct expiry
- Access token authorizes requests before expiry
- Expired access token returns 401
- Refresh token generates new access token
- Password reset invalidates all refresh tokens
- Refresh with invalidated token fails
- Token rotation generates new refresh token
- Concurrent refresh requests handled correctly
- Refresh tokens stored as hashes only
- httpOnly cookies prevent XSS access
- Refresh token reuse detected and blocked
- Stolen refresh token cannot be used after password reset
- Old access token becomes useless within 15 min of password reset
- Token refresh completes under 100ms
- Bulk token invalidation (1000 tokens) completes under 1s
- Cleanup job handles millions of expired tokens
- Concurrent refresh requests don't cause race conditions
Security Metrics:
- Refresh token reuse attempts (potential theft indicator)
- Failed refresh attempts per user (account takeover attempts)
- Bulk invalidation events (password resets, security actions)
Performance Metrics:
- Refresh endpoint latency (p50, p95, p99)
- Database query performance for token validation
- Cache hit rate (if using Redis)
Usage Metrics:
- Active refresh tokens per user (device proliferation)
- Token refresh rate (indicates user activity patterns)
- Average token lifetime before invalidation
Critical Alerts:
- Refresh token reuse spike (>10 incidents/min) → Potential breach
- Refresh endpoint error rate >5% → System degradation
- Database connection failures → Service outage
Warning Alerts:
- Average tokens per user >10 → Possible abandoned sessions
- Token validation query latency >200ms → Performance degradation
The short-lived access token + long-lived refresh token architecture provides the optimal balance between security, user experience, and system performance for modern authentication systems.
Key Takeaway: By reducing the validity window from 24 hours to 15 minutes and enabling immediate refresh token revocation, we've reduced the attack surface by 96% while maintaining seamless user experience through automatic token refresh.
This solution is industry-standard, used by major platforms (Google, GitHub, Auth0, etc.), and aligns with OAuth 2.0 best practices.
Document Version: 1.0
Last Updated: February 12, 2026
Status: Approved for Implementation