Mastering Dual Token Authentication: From Architecture Design to Production Deployment
This comprehensive guide explains why many teams struggle with dual‑token implementations, outlines the three core goals of the mechanism, details threat modeling, design principles, data modeling, JWT claim choices, atomic refresh rotation with Redis Lua, and provides production‑ready Spring Boot code, observability, scaling and security hardening recommendations.
Why simple dual token fails
Projects that only return an Access Token and a Refresh Token quickly encounter issues such as token‑burst overload of the authentication centre, missing Refresh‑Token rotation, inability to revoke sessions, and lack of multi‑device management.
Core goals of dual token
Decouple high‑frequency access verification from low‑frequency session renewal.
Combine stateless verification (Access Token) with stateful session control (Refresh Token).
Provide a tunable balance between security, performance and user experience.
Token responsibilities
Core duty : Access Token – access protected resources; Refresh Token – obtain a new Access Token.
Lifetime : Access Token – 5‑30 minutes; Refresh Token – 7‑30 days.
Verification location : Access Token – gateway or service local verification; Refresh Token – authentication or session centre.
Stateless? Access Token usually is; Refresh Token usually is not.
Invalidation : Access Token – expiry, blacklist, version; Refresh Token – server‑side state & rotation.
Recommended storage : Access Token – memory / short‑term cache; Refresh Token – HttpOnly cookie, secure vault.
Threat model & design goals
Attack surfaces: Access Token theft, Refresh Token theft, brute‑force refresh calls, replay attacks, public‑key cache inconsistency, logout leakage, permission‑change lag, key‑rotation failures.
Low latency : business requests should not depend on synchronous auth‑centre checks.
Revocable : support user‑, device‑, session‑, tenant‑level invalidation.
Traceable : locate login, refresh, device, token family.
Replay‑resistant : detect and handle reused Refresh Tokens.
Scalable : support multi‑region, horizontal scaling.
Evolvable : from monolith to gateway to zero‑trust.
Observable : metrics, logs, audit, alerts.
Production‑level architecture
Client
├─ Web SPA / App / Mini‑Program / BFF
API Gateway
├─ JWT verification
├─ Rate limiting / WAF / risk interception
├─ Tenant parsing / trace injection
Auth Service
├─ Login authentication
├─ Issue Access Token
├─ Rotate Refresh Token
├─ Logout & session revocation
├─ JWK publishing
Session Service
├─ Device session management
├─ Token family management
├─ Risk device freeze
├─ Audit query
Infrastructure
├─ Redis Cluster (RT state, locks, short‑term blacklist)
├─ PostgreSQL / MySQL (session master data, audit archive)
├─ Kafka / RocketMQ (logout broadcast, permission change, audit events)
├─ KMS / Vault (private key management)
└─ Prometheus / Loki / Tempo (metrics, logs, tracing)Key data model
user_id: user identifier session_id: login session primary key device_id: device identifier token_family_id: group of rotating Refresh Tokens jti: unique token ID token_version: session‑level version for bulk invalidation absolute_expire_at: max session lifetime last_refresh_at: timestamp of last refresh status: ACTIVE / REVOKED / LOCKED
JWT claims recommendation
{
"iss": "https://auth.example.com",
"sub": "u_1024001",
"aud": ["gateway", "order-service"],
"iat": 1770000000,
"nbf": 1770000000,
"exp": 1770001800,
"jti": "at_9f6c4...",
"sid": "sess_72f1...",
"did": "dev_8b91...",
"tid": "tenant_001",
"scope": ["order:read", "coupon:claim"],
"token_version": 12
}Do not put full user profile, large permission objects or sensitive personal data into the Access Token; keep it minimal to reduce network overhead and verification cost.
Refresh token generation & storage
public class RefreshTokenGenerator {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
public String generatePlainToken() {
byte[] bytes = new byte[48];
SECURE_RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}Only store the SHA‑256 hash of the token in Redis; keep the plain token on the client.
Login flow
1. User submits credentials (password / SMS / OAuth).
2. Auth Service validates identity.
3. Create a new session record.
4. Create a Refresh Token family.
5. Issue Access Token (short TTL).
6. Return plain Refresh Token once to client.
7. Store Refresh Token hash with session state.
8. Emit login audit event.Access flow
1. Client sends Access Token to API Gateway.
2. Gateway verifies signature locally using JWK.
3. Validate exp/nbf/iss/aud/token_version.
4. Inject user_id, tenant_id, session_id into downstream headers.
5. Resource service executes business logic.Refresh flow (atomic rotation)
1. Client detects AT expiry and calls /auth/refresh with RT.
2. Auth Service looks up RT hash → session.
3. Verify session not absolutely expired.
4. Generate new plain Refresh Token and hash.
5. Execute Redis Lua script to ensure:
• Only the current RT can be rotated.
• Old RT is marked as used.
• Replay is detected.
• The whole operation is atomic.
6. Issue new Access Token and new Refresh Token.
7. Record refresh audit.
8. Return new token pair.Redis Lua script (core logic)
-- KEYS[1] = rt:current:{familyId}
-- KEYS[2] = rt:meta:{oldHash}
-- KEYS[3] = rt:meta:{newHash}
-- KEYS[4] = rt:used:{oldHash}
-- ARGV[1] = oldHash
-- ARGV[2] = newHash
-- ARGV[3] = newMetaJson
-- ARGV[4] = newTtlSeconds
-- ARGV[5] = usedTtlSeconds
local current = redis.call('GET', KEYS[1])
if not current then return {err='SESSION_REVOKED'} end
if current ~= ARGV[1] then
if redis.call('EXISTS', KEYS[4]) == 1 then
return {err='REUSE_DETECTED'}
end
return {err='STALE_TOKEN'}
end
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[4])
redis.call('SET', KEYS[3], ARGV[3], 'EX', ARGV[4])
redis.call('SET', KEYS[4], '1', 'EX', ARGV[5])
redis.call('DEL', KEYS[2])
return 'OK'Logout & forced kick‑out
Mark session status as REVOKED.
Delete or invalidate current Refresh Token state.
If needed, put the Access Token jti into a short‑term blacklist.
Publish session revocation event to gateways/BFFs for cache refresh.
High‑concurrency strategies
Isolate the refresh endpoint with separate thread‑pools.
Localize serialization by locking per familyId instead of a global lock.
Use Redis Lua for atomic rotation.
Apply rate‑limiting on /refresh per user‑id + device‑id.
Implement an idempotent window (1‑3 s) for the same familyId+oldHash.
Blacklist Access Tokens only for high‑risk logout or immediate revocation.
For multi‑region deployments, keep AT verification local and route RT state to the owning region.
Security hardening
Store Refresh Tokens in HttpOnly, Secure, SameSite cookies (Web) or OS keychains (Mobile).
When RT is in a cookie, enforce CSRF tokens and Origin/Referer checks.
Bind Refresh requests with deviceId, userAgent fingerprint, recent IP.
Key rotation: publish new kid in JWK set while keeping old key until all ATs expire.
Permission changes: use token_version for fast batch invalidation; for high‑risk changes, revoke the whole session family.
Audit logs must capture userId, tenantId, sessionId, familyId, jti, clientIp, userAgent, login method, refresh result, replay detection, and risk decision.
Observability
Prometheus metrics examples:
auth_login_success_total
auth_login_failure_total
auth_refresh_success_total
auth_refresh_failure_total
auth_refresh_reuse_detected_total
auth_refresh_latency_ms
auth_access_token_issue_total
auth_session_revoked_total
auth_jwk_fetch_failure_total
auth_redis_lua_failure_totalDashboards should cover business success rates, latency (p95/p99) for /login and /refresh, Redis RTT, JWK cache hit ratio, refresh replay counts, and 401 spikes.
Kubernetes & cloud‑native deployment
Separate workloads: auth-api (login/refresh/logout), session-worker (audit, cleanup, MQ consumption), jwk-publisher (public‑key endpoint).
Pod resources: CPU for JWT signing, Redis I/O, short‑lived connection bursts.
HPA on /refresh service using a custom QPS metric.
Readiness probe must wait for JWK cache and Redis connection before accepting traffic.
Graceful termination: drain traffic, wait for in‑flight requests, then shut down.
Gray‑release: publish new JWK first, then switch signing key, keep old key until all ATs expire.
When to use dual token
Ideal for web/app user login, micro‑service APIs, BFF scenarios, and any system requiring session renewal and active revocation. Not necessary for single‑machine admin consoles, one‑off tasks, or pure machine‑to‑machine calls (use client‑credentials, MTLS, or short‑lived signed credentials instead).
Baseline production parameters
Access Token TTL: 10‑15 minutes.
Refresh Token TTL: 7‑14 days.
Absolute Session TTL: 30 days.
JWK cache time: ~5 minutes.
Refresh idempotent window: 1‑3 seconds.
Blacklist TTL: AT remaining TTL + 1 minute.
Implementation checklist
Define session, family, token_version schema.
Implement Access Token local verification and JWK publishing.
Store Refresh Token hash and implement atomic rotation (Redis Lua).
Add logout, kick‑out, permission‑change invalidation.
Rate‑limit and add idempotent window to /refresh.
Expose Prometheus metrics, audit logs, K8s probes, and gray‑release workflow.
Following this checklist yields a production‑grade authentication system that scales, is observable, and can evolve from a monolith to a zero‑trust architecture.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Cloud Architecture
Focuses on cloud‑native and distributed architecture engineering, sharing practical solutions and lessons learned. Covers microservice governance, Kubernetes, observability, and stability engineering to help your systems run stable, fast, and cost‑effectively.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
