0%

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

🌐 Language: English Version | 中文版

In an OAuth2/OIDC system, JWTs are well-suited as request admission credentials. The gateway can verify signatures locally, check expiration, and parse user and permission information without querying a session store for every request. Horizontal scaling is straightforward.

But browser login state introduces a separate set of concerns: explicit logout, admin-initiated session termination, immediate enforcement of permission changes, and active session tracking. These are stateful governance problems, distinct from the stateless admission that JWTs handle.

This article records a session governance design: adding a Redis SID layer alongside an existing OAuth2/JWT system. The browser carries the SID in an HMAC-signed cookie, the server maintains the SID’s liveness in Redis, and the same SID propagates into the JWT. Browser sessions and API tokens share a single set of revocation signals.

TL;DR

Core ideas:

  • The browser carries a 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: a security stamp for account-level events such as password changes, and a permission version for role changes

Problem Boundary

The strength of JWTs lies in stateless validation. That strength does not need to be challenged.

The problem surfaces when the system begins to require stateful capabilities:

  • After a user explicitly logs out, can the old JWT still access APIs
  • After an admin disables a user, does the old JWT become invalid immediately
  • After a user’s roles are adjusted, are the permissions in the old JWT still trustworthy
  • Can the system see how many active browser sessions a user has
  • Can the system kick out only one device or one browser session

These capabilities fall outside the scope of “does the signature verify.” JWTs handle request admission; browser session governance handles the login state lifecycle. Mixing the two typically leads to one of two extremes: either waiting for tokens to expire naturally, or reverting to full server-side sessions.

The third approach chosen here: preserve the OAuth2/JWT request admission model, and only add a revocable SID for browser sessions.

Why Not Traditional Sessions

Traditional server-side sessions can handle revocation, kick-out, and session tracking, but in this scenario, they are not the most suitable primary model.

First, the system already has a complete OAuth2 authorization code flow and JWT issuance mechanism. The browser session layer needs to supplement revocation capability, not replace the existing token system.

Second, browser page requests and static resource requests are frequent. If all requests depend on centralized session lookups, storage pressure and the blast radius of failures both increase.

Third, business APIs are already under gateway-based JWT admission control. The session governance layer is better suited to providing a clear revocation signal than re-assuming the full authentication context.

So the boundary of this design is:

  • JWTs handle API request admission
  • Cookies carry browser login state
  • Redis SIDs provide session-level revocation signals
  • Database security stamps handle account-level forced invalidation
  • Permission version numbers handle authorization change synchronization

This boundary matters more than the question of “Session vs. Token.”

Trade-Offs Up Front

Adding SIDs gives the system three categories of capability.

First, session-level revocation. Deleting one SID invalidates the corresponding browser session and any JWTs issued through it, without maintaining a token blacklist.

Second, finer-grained invalidation semantics. Logout and kick-out go through the SID; password changes and account disabling go through the security stamp; role adjustments and authorization changes go through the permission version number. Different events no longer crowd into a single field.

Third, the OAuth2 flow remains intact. The SID flows from the Cookie into the OAuth2 Authorization and then into the JWT. The existing authorization chain still holds.

The cost is equally direct.

Redis is no longer just a cache. It becomes session infrastructure. When SID liveness is undeterminable, the system cannot continue to allow requests through. Otherwise, the promises of “logout takes effect immediately” and “admin kick-out takes effect immediately” are broken.

Every browser login state restoration and every gateway authorization check incurs an additional SID lookup. The implementation also needs to handle Cookie signing, SID propagation, security stamp, and permission version number pathways.

This is not free complexity. It suits systems that need session revocation, fast permission enforcement, and admin-initiated session termination. It does not suit pure public APIs with no browser session concept.

Dual-Layer Session Structure

The overall structure can be viewed as three checks.

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 verify + exp check"| Redis
    Redis -->|"2. SID alive + sliding renewal"| DB
    DB -->|"3. security_stamp match"| Cookie

The first layer is the HMAC-signed cookie.

The cookie contains the user identifier, SID, security stamp, and expiration time. The server can perform stateless signature verification and expiration checks first. A passing signature only means the cookie was not tampered with, not that the session is still valid.

The second layer is the Redis SID.

At login, a random SID is generated, written to Redis, and assigned a TTL. Subsequent requests check whether the SID still exists; if so, the browser session is still valid. Logout, kick-out, and expiration can all be handled by deleting the SID or waiting for the TTL to expire.

The third layer is the database security stamp.

Security events such as password changes, admin-initiated password resets, and account disabling update the security_stamp in the user table. Cookies and JWTs also carry the security stamp from the time they were issued. If the stamps do not match during validation, the authentication state is not restored.

If any of the three layers fails, the request is treated as unauthenticated.

The cookie value uses a two-part structure:

1
base64url(JSON payload) . base64url(HMAC-SHA256(payload))

A payload example:

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 Current browser session ID, corresponding to the Redis key
security_stamp Account-level security stamp, used to force-invalidate old sessions
exp Cookie hard expiration time
idp Identity provider identifier
email / display_name Basic user information commonly used for page rendering

Signature comparison uses constant-time comparison, such as MessageDigest.isEqual(). Ordinary byte-by-byte comparison may expose timing attack risk.

HMAC keys should be injected through a secure configuration service, key management system, or environment variables. They must not be committed to code repositories. Key rotation strategies also need to be defined in advance. Otherwise, the cookie signing mechanism becomes dependent on a long-lived fixed key.

Cookie attributes should maintain basic constraints: HttpOnly, Secure, SameSite=Lax, Path=/. If the service is deployed behind a TLS-terminating proxy, the Secure determination should align with the actual external HTTPS entry point.

Redis SID Does One Thing

Sessions in Redis are not designed as “fat sessions.” A thin structure is sufficient:

1
2
3
Key:   session:sid:{sid}
Value: {"uid": 100}
TTL: 43200 seconds

The Redis SID is responsible for only two things:

  • Marking whether this session is still valid
  • Performing sliding renewal on active access

Basic user information is already in the cookie. Permissions, security state, and account status remain authoritative in the database and policy cache. Storing large amounts of permission data in Redis sessions makes permission-change synchronization more complex and amplifies session storage maintenance costs.

Cookie exp and Redis TTL represent two types of boundaries:

  • Cookie exp is the hard upper bound for the browser-held credential, immutable by the browser after signing
  • Redis TTL is the active window for the server-side session, renewable on access

If the business needs to keep active users online, the server needs to refresh the cookie’s exp field and return a new signed cookie via Set-Cookie. Otherwise, Redis renewal only ensures the SID does not expire; the cookie will still become invalid at the hard limit.

This trade-off belongs to session security policy, not just user experience configuration.

Validation Flow

When restoring browser login state, the flow is roughly as follows:

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"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public SessionClaims resolveActiveClaims(Request request, Response response) {
SessionClaims claims = decodeAndVerifyCookie(request);

if (claims == null || claims.sid() == null || claims.securityStamp() == null) {
return null;
}

if (sessionStore.isSidActive(claims.sid())) {
User user = userRepo.findActiveById(claims.userId());

if (user != null && claims.securityStamp().equals(user.getSecurityStamp())) {
return claims;
}
}

clearCookie(request, response);
return null;
}

The three checks correspond to three categories of system semantics:

Layer Check Problem Solved
HMAC Cookie Tampered or expired Prevent forged UID, SID, exp
Redis SID Session still valid Support logout, kick-out, session expiration
DB security stamp Account security state changed Support forced invalidation after password change or account disablement

For malformed input and signature errors, treat as unauthenticated directly. For requests that can be determined as invalid, the cookie can be cleared. Skipping the SID check after HMAC verification passes would regress “revocable session” back to “irrevocable cookie.”

How the SID Enters the JWT

The SID in the browser cookie does not exist in isolation. It propagates to the OAuth2 Authorization and then into the JWT.

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 (write sid + security stamp + permission version)
    Auth-->>B: Return JWT

    B->>GW: API request (Bearer JWT)
    GW->>Auth: Auth callback
    Auth->>Auth: Decode JWT, extract sid
    Auth->>Redis: Check SID alive
    Auth->>Auth: Policy decision
    Auth-->>GW: 200 + user headers
    GW-->>B: Business response

At login, the auth service creates an SID and writes it into a signed cookie.

When the browser subsequently initiates an OAuth2 authorization request, the auth service reads the SID from the cookie and attaches it to the OAuth2 Authorization object.

During token issuance, the SID is written into the JWT claims:

1
2
3
4
5
6
7
8
String sid = context.getAuthorization().getAttribute("sid");

context.getClaims().claim("sid", sid);
context.getClaims().claim("security_stamp", user.getSecurityStamp());

if (isAccessToken) {
context.getClaims().claim("token_version", user.getPermissionVersion());
}

The SID in the cookie and the SID in the JWT are the same value. Once this SID is revoked in Redis, both browser page requests and API requests fail in subsequent validation.

A token blacklist operates at token granularity. The SID operates at session granularity. How many access tokens have been issued within a single browser session does not affect the revocation action; as long as they share the same SID, one session signal can invalidate them all uniformly.

Two Revocation Signals

Session governance should not compress all invalidation scenarios into a single field. Here they are split into two signals:

  • security_stamp: account-level security events
  • permission_version: permission change events

security_stamp is stored in the user table, typically updated when a user changes their password, an admin resets a password, or an account is disabled. The security stamp is written into both the cookie and the JWT. When the current value in the database changes, old cookies and old JWTs both become invalid.

permission_version is also a field in the user table, incremented each time roles are adjusted or authorizations are added or removed. It is primarily written into access tokens and compared against the current policy version during gateway authorization. When the version in an old access token falls behind, the gateway rejects the request, and the client re-runs the authorization flow to obtain a new token.

Splitting the two makes user experience more controllable:

Dimension Security Stamp Permission Version
Trigger scenario Password change, account disabled Role adjustment, authorization added or removed
Impact scope Cookie + JWT Primarily access token
User experience Usually requires re-login Usually just re-obtains token
Update frequency Low Medium

If only one field is used, every permission change could force a full re-login across all devices. Permission changes are typically more frequent than security events. After the split, the invalidation scope more closely matches the actual business event.

Gateway Authorization Chain

When API requests go through the gateway, JWT validation checks not only signature and expiration, but also SID and policy version.

flowchart LR
    Client["Client"] -->|"Bearer JWT"| GW["API Gateway"]
    GW -->|"forward-auth\nforward Authorization 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"]

A typical forward-auth flow:

  1. Client sends a Bearer JWT request to the API gateway
  2. Gateway forwards the Authorization header to the auth service
  3. Auth service decodes the JWT, reads uid, sid, security_stamp, token_version
  4. Checks whether the Redis SID still exists
  5. Reads the user policy, compares security stamp and permission version
  6. After authorization passes, the gateway injects user header information and forwards to the business service

Two modes can be supported here:

  • authenticated: only confirms who sent the request, checks user status, SID, security stamp, and permission version
  • entitled: continues with RBAC, application authorization, and resource authorization on top of authenticated

The difference between the two modes is in authorization depth, not session validation. SID and security stamp remain the common foundational layer.

When Redis Is Unavailable

This design introduces a Redis dependency, so failure semantics must be defined.

If the policy cache is unavailable, the system can fall back to the database. Policy data has an authoritative source; Redis is only a cache.

SID liveness checks are different. The SID is the source of truth for whether a session is still valid. When Redis is unavailable, the system does not know whether the session has been revoked. Continuing to allow requests through would break the promises of “logout takes effect immediately” and “admin kick-out takes effect immediately.”

Therefore, the SID validation path is better suited to fail-closed behavior: when Redis is unavailable, reject session restoration and API authorization.

This also means Redis high availability is not optional. Master-replica replication with Sentinel, Redis Cluster, or other high-availability solutions that meet deployment requirements need to be designed as part of the session infrastructure, not added after going live.

Logout and Kick-Out

Logout clears both layers:

1
2
3
4
5
6
public void logout(Request request, Response response) {
String sid = cookieService.resolveSid(request);

sessionStore.invalidateSid(sid);
cookieService.clearCookie(request, response);
}

After the Redis SID is deleted, the impact covers both pathways simultaneously:

  • Browser requests: the cookie signature may still verify, but the SID check fails
  • API requests: the JWT may not have expired, but the SID check in the JWT fails

Kicking out a specific browser session is essentially the same: delete the corresponding SID.

Invalidating all sessions for a user is better handled by updating the security_stamp. This causes the security stamp in all of that user’s old cookies and old JWTs to mismatch.

Applicability Boundary

Scenario Judgment
Internal systems requiring immediate logout Suitable
Admin consoles requiring admin kick-out Suitable
Permission changes need fast enforcement Suitable
Browser sessions and API tokens need unified governance Suitable
Pure public APIs with no browser session concept Not very suitable
Cross-top-level-domain SSO Requires a protocol-level solution, not just cookies
Extremely high concurrency that cannot bear centralized session dependency Needs Redis architecture reassessment

This design is not meant to prove that cookies are better than JWTs, or that sessions are more secure than tokens. What it does is separation of concerns:

  • JWTs handle request admission credentials
  • Cookies carry browser login state
  • Redis SIDs provide session revocation signals
  • Security stamps handle account-level forced invalidation
  • Permission version numbers handle authorization change synchronization

After these signals are separated, what each field affects, where it is validated, and how it behaves during failures all become clearer. The architectural benefit comes not just from “adding a Redis layer,” but from invalidation semantics being split to the right granularity.

Further Reading