
双 JWT 认证架构:结合短期访问令牌、刷新 Cookie 与 Redis 撤销机制
来源标题:Dual JWT Authentication: Security, Redis, and Trade-Offs
技术博客介绍双 JWT 认证架构,通过短期访问令牌、长期刷新 Cookie 和 Redis 支持的会话撤销机制,在保持用户长期登录的同时减少每次请求的数据库查询。文章同时讨论了无状态续期方案的局限性。
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
BLOG INDEX SECURITY/ November 7, 2026
Dual JWT Authentication: Security, Redis, and Trade-Offs
Learn how dual JWT authentication combines short-lived access tokens, refresh cookies, and Redis revocation—and where stateless renewal falls short.
Authentication is one of those backend features that looks simple until you need to make it secure, scalable, and inexpensive.
A user logs in, receives a token, and includes it in subsequent requests. The server verifies the token and allows access.
But real applications need more than that. Users expect to stay signed in for weeks, log out from multiple devices, recover from interrupted connections, and have their sessions terminated when credentials are compromised.
Meanwhile, backend teams want to avoid unnecessary database queries on every authenticated request.
These requirements pull authentication architecture in different directions.
One possible compromise is a Dual JWT architecture: short-lived access tokens sent through the Authorization header, longer-lived refresh tokens stored in secure cookies, and a small Redis-backed mechanism for revoking sessions.
The approach can reduce database traffic significantly. But its security properties depend on how refresh tokens are managed, how revocation works, and which attacks the application needs to defend against.
Let’s examine the architecture, its implementation in Node.js, and the trade-offs that are easy to overlook.
QUICK ANSWER
A practical dual-token design uses a short-lived access JWT for API calls and a more carefully protected refresh credential for session renewal. Redis can provide revocation state without adding a lookup to every API request. The key trade-off is that stateless refresh-token renewal cannot detect replay; applications that need strong compromise recovery should keep refresh-token or session state.
Consider an application that uses one JWT for authentication.
Authorization: Bearer eyJhbGciOi...
Every protected API request includes that token.
The server verifies its signature and expiration time without querying a session database.
This is convenient, but the token’s lifetime creates a problem.
If it expires after 15 minutes, users need a way to obtain another token without repeatedly entering their credentials.
If it remains valid for 30 days, stealing it may give an attacker access for a long time.
Neither option is particularly attractive.
Traditional session authentication usually stores an opaque session identifier in an HttpOnly cookie.
The browser sends that cookie automatically, and the server looks up the associated session.
This approach has important advantages. Tokens aren’t exposed to ordinary JavaScript through document.cookie, and individual sessions can be invalidated centrally.
However, cookie-based authentication also requires appropriate CSRF defenses because browsers attach eligible cookies automatically.
That doesn’t make cookie sessions inherently insecure. Properly implemented server-side sessions remain an excellent choice for many web applications.
The question is whether the application needs the additional flexibility of separate access and refresh credentials.
The Dual JWT approach separates API access from session renewal.
The access token is short-lived and intended for frequent use.
The refresh token is longer-lived and sent only to the authentication service when the client needs a new access token.
Browser Authentication API | | | POST /auth/login | |----------------------------------->| | | | Access JWT in response body | | Refresh JWT in HttpOnly cookie | |<-----------------------------------| | | | GET /api/profile | | Authorization: Bearer <access> | |----------------------------------->| | | | Profile data | |<-----------------------------------| | | | POST /auth/refresh | | Cookie: refresh=<jwt> | |----------------------------------->| | | | New access JWT | | New refresh cookie | |<-----------------------------------|
The API doesn’t need to query a database to verify every access token.
Instead, it validates the JWT signature and claims.
The refresh endpoint performs additional checks, including whether the user’s sessions have been revoked.
This separation is the foundation of the architecture.
An access token should contain only the information needed to authorize API requests.
{ "iss": "https://auth.example.com", "aud": "https://api.example.com", "sub": "user_123", "jti": "unique-token-id", "iat": 1794000000, "exp": 1794000300, "token_use": "access" }
The important claims are:
An authentication service must never accidentally accept a refresh token where an access token is expected.
Using distinct audiences, validation rules, and preferably separate signing keys reduces this risk.
The following example uses the jose library.
npm install jose
import { SignJWT } from 'jose'; import { randomUUID } from 'node:crypto';
const encoder = new TextEncoder();
const accessKey = encoder.encode( process.env.ACCESS_TOKEN_SECRET );
export async function createAccessToken(userId) { return new SignJWT({ token_use: 'access', }) .setProtectedHeader({ alg: 'HS256' }) .setIssuer('https://auth.example.com') .setAudience('https://api.example.com') .setSubject(userId) .setJti(randomUUID()) .setIssuedAt() .setExpirationTime('5m') .sign(accessKey); }
This is an illustrative implementation using a symmetric key.
Production deployments should use securely generated secrets of sufficient entropy, protect them through a secrets-management system, and establish a signing-key rotation strategy.
For architectures with separate authentication and resource servers, asymmetric signing can simplify verification because API services don’t need access to the private signing key.
import { jwtVerify } from 'jose';
export async function verifyAccessToken(token) { const { payload } = await jwtVerify( token, accessKey, { issuer: 'https://auth.example.com', audience: 'https://api.example.com', algorithms: ['HS256'], requiredClaims: [ 'sub', 'jti', 'iat', 'exp', 'token_use', ], } );
if (payload.token_use !== 'access') { throw new Error('Invalid token type'); }
return payload; }
Signature verification alone isn’t sufficient.
The server must also validate the token’s issuer, audience, expiration, algorithm, and intended purpose.
An expired token should result in an authentication failure, normally an HTTP 401 response.
The refresh token represents the user’s ability to continue an authenticated session.
Because it remains valid longer than the access token, protecting it is particularly important.
{ "iss": "https://auth.example.com", "aud": "https://auth.example.com/refresh", "sub": "user_123", "sid": "session_456", "jti": "unique-refresh-id", "iat": 1794000000, "exp": 1796592000, "session_expiry": 1801776000, "token_use": "refresh" }
Two additional fields matter here.
sid identifies the logical session created during login.
session_expiry establishes an absolute maximum lifetime. Even if refresh credentials are renewed, the session cannot continue beyond that deadline.
For example, a system might use a 30-day inactivity window and a 90-day absolute session lifetime.
res.cookie('refresh_token', refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh', maxAge: 30 * 24 * 60 * 60 * 1000, });
Each attribute has a purpose.
HttpOnly prevents ordinary JavaScript from reading the cookie through browser APIs.
Secure restricts transmission to secure connections.
SameSite=Strict limits cross-site cookie transmission.
Path restricts the requests to which the browser attaches the cookie.
However, none of these attributes is a complete security solution.
An XSS vulnerability may still allow malicious JavaScript to make authenticated same-origin requests.
Likewise, SameSite distinguishes sites rather than origins. Applications sharing a registrable domain across multiple subdomains must consider same-site, cross-origin attacks.
Sensitive cookie-authenticated endpoints should also validate request origins and apply appropriate CSRF defenses.
For cross-site frontend and authentication deployments, SameSite=Strict may prevent the intended refresh flow altogether. Cookie policy, CORS, credentials mode, and deployment topology must be designed together.
A common refresh-token strategy issues a new refresh token whenever the existing one is used.
A secure rotating system typically invalidates the previous token and detects attempts to reuse it.
Login | v Refresh T1 | | Refresh request v Refresh T2 | | Refresh request v Refresh T3
Once T2 replaces T1, T1 should no longer be usable.
If an attacker later presents T1, the server can recognize that an invalidated credential has been reused.
To reject previously used refresh tokens, the server needs a reliable way to distinguish the current token from older ones.
One approach stores the current refresh-token identifier for each session.
Another stores consumed token identifiers or token-family information.
Either way, the server must maintain some state.
The refresh operation also needs appropriate concurrency control.
Two browser tabs might attempt to refresh simultaneously. Without careful handling, both requests could read the same token state and race to replace it.
Atomic updates, short grace periods, and coordinated client-side refresh requests are common ways to manage these situations.
The architecture described in the original Dual JWT proposal deliberately avoids storing individual refresh-token states.
When a refresh JWT is presented, the server validates it and issues a replacement, but does not invalidate the previous JWT.
That reduces storage and coordination requirements.
It also means several refresh tokens from the same session can remain valid simultaneously.
Issuing a new refresh JWT without invalidating the previous one is token renewal, not replay-resistant refresh-token rotation.
If an older refresh token is stolen, an attacker may continue using it until its expiration or until a broader revocation mechanism blocks it.
The fact that a cookie is HttpOnly doesn’t eliminate that risk. Cookies can be compromised through malware, browser or infrastructure vulnerabilities, and other attacks.
OAuth 2.0 Security Best Current Practice, defined in RFC 9700, requires public clients to use sender-constrained refresh tokens or refresh-token rotation with replay detection.
Consequently, the fully stateless renewal approach should not be presented as equivalent to compliant refresh-token rotation for those clients.
It is a security trade-off that requires a separate threat-model assessment.
JWT validation is attractive because it doesn’t require a database lookup.
What happens when a user changes their password, reports a compromised account, or selects “Log out on all devices”?
A JWT that passes signature and expiration checks remains cryptographically valid until it expires.
To revoke credentials early, the system needs additional information.
One lightweight approach uses a per-user revocation timestamp.
auth:user:user_123:revoked_before = 1794000000
When validating a refresh token, compare its issue timestamp with that cutoff.
Token issued before cutoff → reject Token issued after cutoff → potentially valid
This allows the system to invalidate all older refresh tokens belonging to a user with a single logical state update.
Instead of storing every refresh token, Redis stores revocation information only when necessary.
npm install redis
import { createClient } from 'redis';
export const redis = createClient({ url: process.env.REDIS_URL, });
redis.on('error', (error) => { console.error('Redis error:', error); });
await redis.connect();
A simplified revocation check:
export async function isRefreshRevoked(payload) {
const cutoff = await redis.get(
auth:user:${payload.sub}:revoked_before
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。