One Field Invalidates Every Cookie for That Tenant
We built a password-gated training portal where each client gets their own subdirectory: /training/freedom-ag, /training/next-client, and so on. Each tenant has a password and a cookie signed with a shared HMAC secret. The cookie is good for 30 days.
That raised a question we knew was coming: what happens if we need to force everyone to log back in?
The obvious answer is "change the password." But that only helps if the clients don't already have valid cookies. A cookie minted before the password changed is still cryptographically valid. They'd sail right through without ever entering the new password.
The other obvious answer is "rotate the HMAC secret." That works, but it invalidates every tenant at once. If you need to kick one client's session without touching the others, you can't.
The Version Field
Each tenant config has one field that handles this: accessVersion.
{
"tenant": "freedom-ag",
"accessVersion": 0,
"modules": [ ... ]
}
The value is baked into every cookie signed for that tenant. The cookie payload looks like this:
<tenant>.<expiresAt>.<version>.<hmac>
The HMAC is computed over <tenant>|<expiresAt>|<version> as a single string. Changing any of those three values breaks the signature.
But the version check doesn't stop at the signature. When the middleware verifies a cookie, it reads the current accessVersion from the tenant JSON and compares it against the version encoded in the cookie. A mismatch is its own failure reason: stale-version.
if (Number(versionRaw) !== currentVersion) {
return { ok: false, reason: "stale-version" };
}
So if someone holds a cookie minted when accessVersion was 0, and we bump the JSON to 1 and redeploy, their cookie fails immediately on the next request. They land on the login page.
Why This Is the Right Shape
The version field isolates the invalidation to one tenant. If freedom-ag needs a reset, we bump accessVersion in freedom-ag.json. Every other tenant's cookies are unaffected.
This also keeps the architecture clean. The shared HMAC secret is long-lived and not tied to any specific client relationship. Rotating it is a real operation with actual risk. Bumping a version number in a JSON file is a one-line change, committed and deployed in minutes.
There's no session store, no database query on every request, no revocation list. The middleware checks a field it's already reading to serve the page. The version check costs nothing.
The Cookie Also Carries Server-Side Expiry
One other detail worth noting: the middleware enforces expiry on the server side, not just via the browser's Max-Age.
The cookie encodes an expiresAt timestamp. The middleware compares it against the current time on every request. A stolen cookie that has been modified to extend its expiry breaks the HMAC. An unmodified cookie that has passed its expiry is rejected by the server even if the browser would have kept it.
if (Number(expiresRaw) <= nowSeconds) {
return { ok: false, reason: "expired" };
}
Browser Max-Age is a client hint. The server check is the actual enforcement.
The full chain is: signature valid, tenant matches, not expired, version current. All four. A valid cookie from last week that fails the version check is not a valid cookie.