0%

HMAC Cookie + Redis SID Dual-Layer Sessions: Browser Session Governance in an OAuth2/JWT System

🌐 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:

  1. 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
  2. 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.

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
2
3
4
5
6
7
8
9
{
"uid": 100,
"email": "user@example.com",
"display_name": "张三",
"sid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"idp": "EMAIL",
"security_stamp": "abc123def456",
"exp": 1745577600
}
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
2
3
4
5
6
7
8
9
10
11
12
// Issue: sign the payload with HMAC-SHA256
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey, "HmacSHA256"));
byte[] signature = mac.doFinal(encodedPayload.getBytes(UTF_8));
String cookieValue = base64url(encodedPayload) + "." + base64url(signature);

// Verify: recompute the signature and compare in constant time
byte[] expected = mac.doFinal(receivedPayload.getBytes(UTF_8));
byte[] actual = base64urlDecode(receivedSignature);
if (!MessageDigest.isEqual(expected, actual)) {
return null; // Signature mismatch
}

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.

1
2
3
4
5
6
7
ResponseCookie cookie = ResponseCookie.from(cookieName, value)
.httpOnly(true)
.secure(request.isSecure()) // Dynamic check, depends on X-Forwarded-Proto
.sameSite("Lax")
.path("/")
.maxAge(Duration.ofSeconds(43200))
.build();
  • HttpOnly: JavaScript cannot read the cookie, reducing the risk of XSS-based theft
  • Secure: 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 on X-Forwarded-Proto
  • SameSite=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
2
3
byte[] bytes = new byte[16];
SecureRandom.getInstanceStrong().nextBytes(bytes);
String sid = HexFormat.of().formatHex(bytes); // 32-character hex

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
2
3
Key:   session:sid:{sid}
Value: {"uid": 100}
TTL: 43200 seconds (12 hours, configurable)

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
2
3
4
5
6
7
8
public boolean isSidActive(String sid) {
Boolean exists = redis.hasKey(key(sid));
if (Boolean.TRUE.equals(exists)) {
redis.expire(key(sid), ttlSeconds, TimeUnit.SECONDS); // Reset TTL
return true;
}
return false;
}

Each SID validation also resets the TTL. Active users do not drop out because of timeout.

There are two expiration mechanisms here:

  • The embedded exp in 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public SessionClaims resolveActiveClaims(Request request, Response response) {
// 1. Parse cookie + verify HMAC + check exp
SessionClaims claims = decodeAndVerifyCookie(request);
if (claims == null || claims.sid() == null || claims.securityStamp() == null) {
return null;
}

// 2. Redis SID alive check + sliding renewal
if (sessionStore.isSidActive(claims.sid())) {
// 3. DB user status + security stamp match
User user = userRepo.findActiveById(claims.userId());
if (user != null && claims.securityStamp().equals(user.getSecurityStamp())) {
return claims;
}
}

// If any layer fails, no valid authentication state can be restored
clearCookie(request, response);
return null;
}

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
2
String sid = sessionStore.createAndActivate(userId);
cookieService.writeSignedCookie(request, response, auth, sid, 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
2
3
4
5
6
7
8
9
10
11
// Decorator for OAuth2AuthorizationService
public void save(OAuth2Authorization authorization) {
String sid = authorization.getAttribute("sid");
if (sid == null) {
sid = cookieService.resolveSidFromCurrentRequest();
authorization = OAuth2Authorization.from(authorization)
.attribute("sid", sid)
.build();
}
delegate.save(authorization);
}

3. SID Is Written into the JWT

During token issuance, the SID is read from the Authorization and written into the JWT claims:

1
2
3
4
5
6
7
String sid = context.getAuthorization().getAttribute("sid");
context.getClaims().claim("sid", sid);
context.getClaims().claim("security_stamp", user.getSecurityStamp());
// Permission version is written only to the access token
if (isAccessToken) {
context.getClaims().claim("token_version", user.getPermissionVersion());
}

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
2
3
4
// Policy decision engine
if (!currentPolicy.getPermissionVersion().equals(tokenVersion)) {
return denied(TOKEN_VERSION_MISMATCH);
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private UserPolicy getUserPolicy(Long uid) {
// Prefer Redis
try {
UserPolicy cached = redis.get(policyCacheKey(uid));
if (cached != null) return cached;
} catch (Exception e) {
log.warn("Redis unavailable, fallback to DB");
}

// Fallback to DB
User user = userRepo.findActiveById(uid);
if (user != null) {
UserPolicy policy = UserPolicy.from(user);
// Try to refill Redis
try { redis.set(policyCacheKey(uid), policy, 300, SECONDS); }
catch (Exception ex) { log.warn("Cache refill failed"); }
return policy;
}

return null; // Both Redis and DB are unavailable
}

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
2
3
4
5
public void logout(Request request, Response response, Authentication auth) {
String sid = cookieService.resolveSid(request);
sessionStore.invalidateSid(sid); // Delete Redis SID
cookieService.clearCookie(request, response); // Clear Cookie
}

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 exp preserves 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

Further Reading