🌐 Language: English Version | 中文版
In an IAM system based on OAuth2/OIDC, the gateway uses JWTs as the admission control mechanism for API requests. Once the protocol flow is working, browser-side session management becomes a separate engineering problem: JWTs are naturally stateless after issuance, while permission changes, explicit logout, and session tracking all require additional governance mechanisms.
This article documents a dual-layer session design based on HMAC-signed cookies and Redis-backed SIDs. The browser login state and the upper-layer JWT share the same Session ID, so browser sessions, API tokens, and revocation signals can enter the same lifecycle model.
TL;DR
This article records a browser session governance design. It assumes readers are already familiar with the basics of OAuth2/JWT, and focuses on architectural trade-offs and key design decisions.
Core ideas:
- The browser carries the session identifier in an HMAC-signed cookie, which is stateless and tamper-resistant
- Redis stores the Session ID (SID), which is stateful and revocable
- The SID flows from the browser cookie into the JWT; revoking one SID invalidates that browser session and the tokens derived from it
- Two revocation signals are used: a security stamp for account-level events such as password changes, and a permission version for role changes
JWT Governance Gaps in the Browser
Using JWT validation at the gateway for API requests is fine at the protocol level. But when the system needs the following capabilities, the stateless nature of a pure JWT approach creates extra governance cost:
- Session revocation: after an administrator disables a user, already-issued JWTs remain valid until expiration
- Explicit logout: the frontend can clear local token storage, but the JWT itself does not become invalid; if intercepted, it can still be used
- Permission changes taking effect: after user roles are changed, the permissions embedded in old JWTs are still being used
- Session tracking: the system cannot count active sessions or kick out a specific session
The root cause is not that JWT is wrong. It is simply better suited for stateless validation. When revocation, specific-session kick-out, or active-session tracking is required, a stateful session signal needs to be added outside JWT.
Why Not Use a Traditional Session Directly
Traditional server-side sessions can solve the problems above, but there are two concerns:
- Every request needs a storage lookup: browser page requests are more frequent than API requests, and if every static resource request triggers a session lookup, the storage pressure is not ideal
- Integration with the OAuth2 flow: the system already has a complete OAuth2 authorization code flow and JWT issuance mechanism. What it needs is a browser session layer parallel to the existing system, not a replacement for it
The design goal is: add a lightweight browser session management layer outside the JWT system, support revocation and session tracking, and control the additional cost on each request.
Dual-Layer Architecture
The overall structure has three validation layers:
flowchart LR
subgraph Browser["Browser"]
Cookie["Signed Cookie\n(HMAC-SHA256)"]
end
subgraph Server["Server"]
Redis["Redis\nsession:sid:{sid}"]
DB["Database\nuser.security_stamp"]
end
Cookie -->|"1. HMAC verification + exp check"| Redis
Redis -->|"2. SID alive + sliding renewal"| DB
DB -->|"3. security_stamp match"| Cookie
Layer 1: HMAC-signed cookie - stateless, tamper-resistant, and able to validate expiration independently. The cookie contains the user identifier, Session ID, security stamp, and expiration time. The whole payload is signed with HMAC-SHA256.
Layer 2: Redis SID - stateful, revocable, and capable of sliding renewal. A random Session ID is generated at login, stored in Redis, and assigned a TTL. After each successful validation, the TTL is reset so active users do not drop out because of inactivity timeout.
Layer 3: database security stamp - an account-level security signal. Each user has a security stamp field. Security events such as password changes and account disablement update this value. During validation, the stamp in the cookie is compared with the current value in the database. If they do not match, the authentication state is not restored.
If any of the three layers fails, no valid authentication state can be restored. In scenarios where the server can determine the session is invalid, it can clear the cookie and treat the request as unauthenticated.
Cookie Layer Design
Cookie Format
All information is encoded into one cookie in this format:
1 | base64url(JSON payload) . base64url(HMAC-SHA256(payload)) |
The dot separates the two parts. The first part is the base64url-encoded payload, and the second part is the HMAC-SHA256 signature over the first part. The structure is similar to JWT, but it uses symmetric HMAC rather than RSA, which makes validation faster and key management simpler.
Payload Content
1 | { |
| Field | Purpose |
|---|---|
uid |
Local user ID |
sid |
Session ID, associated with the session record in Redis |
security_stamp |
Security stamp, compared with the current database value |
exp |
Expiration time in epoch seconds, defaulting to 12 hours |
idp |
Identity provider identifier |
email / display_name |
Basic user information |
Putting security_stamp into the cookie is an important design choice: each validation checks not only the signature, but also whether the account security state has changed.
Signing and Verification
1 | // Issue: sign the payload with HMAC-SHA256 |
Signature comparison uses MessageDigest.isEqual() rather than Arrays.equals(). The former is a constant-time comparison and prevents guessing signature bytes through response timing differences, also known as a timing attack. This attack surface is easy to overlook.
Cookie Attributes
1 | ResponseCookie cookie = ResponseCookie.from(cookieName, value) |
HttpOnly: JavaScript cannot read the cookie, reducing the risk of XSS-based theftSecure: transmitted only over HTTPS.request.isSecure()is evaluated dynamically because the service may be behind a TLS-terminating proxy; a high-priority filter needs to rewrite the result based onX-Forwarded-ProtoSameSite=Lax: allows top-level navigation to carry the cookie while blocking cross-site POST, a trade-off between security and usability
Redis SID Layer Design
Session ID Generation
1 | byte[] bytes = new byte[16]; |
A 16-byte random value provides 128 bits of entropy, making collision probability negligible.
Redis Storage
The Redis record is intentionally minimal and stores only the necessary user identifier. The key below is only illustrative; a real project can add prefixes and isolation dimensions according to its naming conventions:
1 | Key: session:sid:{sid} |
The full session context is not stored in Redis. It is distributed across the cookie (basic user information) and the database (permissions and security stamp). The only responsibilities of the Redis SID layer are to mark whether a session is still valid and to perform sliding renewal.
Reasons for not using a “fat session”:
- Permission data can be large, sometimes containing hundreds of authorization records, which adds serialization cost and maintenance complexity
- When permissions change, only the version number in the database needs to be updated; there is no need to synchronize all sessions
- The cookie already carries basic user information, so Redis does not need to duplicate it
Sliding Renewal
1 | public boolean isSidActive(String sid) { |
Each SID validation also resets the TTL. Active users do not drop out because of timeout.
There are two expiration mechanisms here:
- The embedded
expin the cookie is a hard upper bound, and cannot be modified after signing - The Redis SID TTL is an elastic window, reset on each validation
The practical effect is: cookie exp controls the hard upper bound, while Redis TTL controls the active window. Whether to allow a longer sliding session depends on the business trade-off between security and user experience.
Three-Layer Validation Flow
When a browser request attempts to restore login state, it goes through three validation layers:
flowchart TD
A["Request arrives"] --> B{"Parse signed cookie"}
B -->|"Missing"| Z["Unauthenticated"]
B -->|"Present"| C{"HMAC verification"}
C -->|"Mismatch"| Z
C -->|"Passed"| D{"Check exp"}
D -->|"Expired"| Z
D -->|"Not expired"| E{"Redis SID alive?"}
E -->|"Missing"| Z
E -->|"Present + renewed"| F{"DB security stamp match?"}
F -->|"Mismatch"| Z
F -->|"Match"| G["Authenticated"]
Z --> H["Unauthenticated / cookie may be cleared"]
The corresponding core validation method:
1 | public SessionClaims resolveActiveClaims(Request request, Response response) { |
Each layer has a different focus:
| Layer | Check | Threat Defended Against |
|---|---|---|
| HMAC signature | Whether the cookie was tampered with | Forged SID or exp |
| Redis SID | Whether the session is still valid | Logout, kick-out, expiration |
| DB security stamp | Whether account security state has changed | Password change, account disabled |
If any layer fails, no valid authentication state can be restored. For scenarios that can be determined as invalid, the server can clear the cookie. For malformed input or signature mismatch, the request can simply be treated as unauthenticated. The key point is to avoid a fallback that “only verifies the cookie and skips the SID.”
SID Lifecycle Propagation
The core design of this approach is: the browser-side SID is not isolated. It propagates into the JWT, allowing browser sessions and API tokens to use the same revocation signal.
sequenceDiagram
participant B as Browser
participant Auth as Auth Service
participant Redis as Redis
participant GW as API Gateway
B->>Auth: Login (password / federated login)
Auth->>Redis: Create SID
Auth-->>B: Set-Cookie: signed cookie (with sid)
B->>Auth: OAuth2 authorization request (with Cookie)
Auth->>Auth: Extract sid from Cookie
Auth->>Auth: Attach sid to OAuth2 Authorization
Auth->>Auth: Issue JWT (with sid + security stamp + permission version)
Auth-->>B: Return JWT
B->>GW: API request (Bearer JWT)
GW->>Auth: Auth callback
Auth->>Auth: Decode JWT and extract sid
Auth->>Redis: Check SID alive
Auth->>Auth: Policy decision
Auth-->>GW: 200 + user headers
GW-->>B: Business response
The propagation has three steps:
1. Create SID at Login
Whether the login uses a password or a federated identity provider, a SID is created after successful authentication:
1 | String sid = sessionStore.createAndActivate(userId); |
2. SID Flows into OAuth2 Authorization
When the browser starts the OAuth2 authorization request with the cookie, a decorator extracts the SID from the cookie and attaches it to the Authorization object:
1 | // Decorator for OAuth2AuthorizationService |
3. SID Is Written into the JWT
During token issuance, the SID is read from the Authorization and written into the JWT claims:
1 | String sid = context.getAuthorization().getAttribute("sid"); |
The SID in the JWT and the SID in the browser cookie are the same value. Once this SID is revoked in Redis, the browser session and the JWTs issued through that session become invalid in subsequent validation. For account-level revocation across all sessions, a global signal such as the security stamp is a better fit.
Two Revocation Signals
The system has two independent revocation signals with different responsibilities.
Security Stamp: Account-Level Security Events
The security stamp is stored in the user table. Typical trigger scenarios include:
- User changes password
- Administrator resets password
- Account is disabled, or another security event requires forced re-authentication
- Any other event that needs to invalidate all sessions
During validation, the security stamp in the cookie and JWT is compared with the current database value. If it does not match, the request is rejected.
Effect: after a password change, the old cookie and old JWT both carry an outdated security stamp and become invalid. There is no need to find every token individually, and no need to wait for JWT natural expiration.
Permission Version: Permission Changes
permission_version is a monotonically increasing version number in the user table. It increments by 1 whenever permissions change. It is written only into the access token and compared during gateway authorization:
1 | // Policy decision engine |
After permissions change, the version in the old JWT no longer matches the current version, so the request is rejected. The client can run the authorization flow again to obtain a new JWT.
Why Split Them
| Dimension | Security Stamp | Permission Version |
|---|---|---|
| Trigger scenario | Password change, account disabled | Role adjustment, authorization added or removed |
| Scope | Cookie + JWT both invalidated | Mainly affects JWT |
| Validation location | Cookie validation + gateway authorization | Gateway authorization only |
| Update frequency | Low, security events | Medium, permission changes |
| User experience | Requires re-login | Frontend can obtain a new JWT automatically |
Permission changes are much more frequent than security events. If only one field is used, every permission change may force the user to log in again. After the split, permission changes mainly trigger token refresh, while password changes and session revocation trigger re-login.
Gateway Authorization
API requests go through a separate validation path using the gateway’s forward-auth mechanism:
flowchart LR
Client["Client"] -->|"Bearer JWT"| GW["API Gateway"]
GW -->|"forward-auth\nAuthorization header"| Auth["Auth Service"]
Auth -->|"1. Decode JWT"| Auth
Auth -->|"2. SID alive check"| Redis["Redis"]
Auth -->|"3. Policy decision"| Policy["Policy cache / DB"]
Auth -->|"200 + user headers"| GW
GW -->|"Inject request headers"| Svc["Business Service"]
Two modes are supported:
- entitled mode (default): full RBAC, including user status, permission version, security stamp, and application authorization
- authenticated mode: checks only user status, permission version, and security stamp, without application authorization. This is suitable for scenarios that only need to confirm “who sent this request”
The difference between the two modes is the entitlement check. SID validation and the two revocation signals are required in both.
Policy Cache and Fallback
Policy data uses Redis as the primary source and the database as fallback:
1 | private UserPolicy getUserPolicy(Long uid) { |
When both Redis and DB are unavailable, the policy cannot be determined and the decision should be rejected. This is a choice that prioritizes security over availability.
When permissions change, the cache is actively deleted:
1 | redis.delete(policyCacheKey(uid)); |
The next request triggers a cache miss and reloads from the database. Cache TTL is only a fallback. The normal path should rely on active invalidation and permission versions, so old tokens are rejected as quickly as possible.
Logout
Logout clears both layers:
1 | public void logout(Request request, Response response, Authentication auth) { |
After the Redis SID is deleted:
- Subsequent browser requests: cookie signature verification may pass, but the SID check fails, so the path is fail-closed
- Already-issued JWTs: gateway authorization fails at the SID check, so the request is rejected
- For a single browser session, there is no need to maintain a token blacklist or wait for JWT natural expiration
Trade-Off Analysis
Benefits
- Session-level revocation: revoking one SID invalidates both the browser session and the corresponding API tokens, without a token blacklist
- Layered dual signals: security events force re-login, while permission changes only require obtaining a new JWT
- Natural integration with OAuth2: the SID flows from the cookie into OAuth2 and then into the JWT, with no additional token management layer
- Sliding session: Redis TTL supports the active window, while cookie
exppreserves the hard upper bound
Costs
- Redis dependency: SID validation depends on Redis. The design should not fall back to “HMAC verified, so skip SID.” Production environments need Redis high availability and clear failure semantics
- Additional request cost: restoring browser login state and gateway authorization each require an extra SID check and renewal
- Implementation complexity: compared with pure JWT, this adds cookie signing, Redis SID, security stamp comparison, SID propagation, and policy cache fallback
Suitable Scenarios
| Scenario | Judgment |
|---|---|
| Internal systems that need revocation and kick-out | Suitable |
| Permission changes need to take effect quickly | Suitable |
| Browser sessions and API tokens need unified governance | Suitable |
| Pure public API with no session concept | Not suitable; JWT is simpler |
| Cross-top-level-domain SSO | Not suitable; cookies are constrained, a protocol-level solution is needed |
| Extremely high concurrency where centralized session dependency is unacceptable | Not suitable; Redis architecture and fallback strategy need to be reassessed |