TL;DR: A session is the server-approved continuity between one successful sign-in and later requests. For a fintech app accepting Google and GitHub identities, use a random opaque browser token, store only its hash, bind the server-side record to an internal user, and check that record on every authenticated request. Revocation matters because a signed-in browser can remain authorized after the event that made its access unsafe. The practical balance is targeted revocation for ordinary account changes, with a user-wide cutoff for password recovery, suspected takeover, or a sensitive identity-linking change.
The provider proves control of an external identity during sign-in; it should not become the application's permanent session boundary. After callback validation and identity resolution, the Node.js service creates its own session. The browser receives an HttpOnly, Secure cookie, while the database keeps the internal user ID, a token hash, timestamps, and revocation state. Every transfer-screen request crosses that local boundary. This adds one lookup, but it buys an immediate kill switch without asking the user to sign in on every page.
Why does session revocation actually matter in a concrete case?
Usually, it should hold a credential that refers to session state rather than the state itself. A useful mental model is a hotel key card: possession opens a specific door until the hotel's system disables it. The card does not need to contain the guest ledger.
One record, one decision.
That distinction prevents a common category error. Authentication is the event that establishes an identity claim; a session carries the application's decision to continue trusting a client after that event. Authorization still happens per request. A valid session for user usr_7f31 does not, by itself, permit a wire from an account that user cannot control.
The record needs enough information to answer a small set of questions: which internal user owns it, when it expires, whether it was revoked, and which security event created or invalidated it. Keep provider access tokens out of the browser session cookie. If the application needs them later, store them separately with narrower access controls and lifecycle rules.
Cookies add their own controls. HttpOnly prevents JavaScript from reading the cookie, Secure restricts it to secure transport, and a __Host- prefix requires a secure cookie with Path=/ and no Domain attribute. SameSite helps constrain cross-site sending, but it does not replace CSRF protection for every deployment shape. Short version: the cookie is a bearer credential. Treat accidental disclosure as access, not as harmless metadata.
A small Node.js implementation
The following TypeScript keeps the important boundary visible. It assumes TLS termination is correctly configured, the callback has already validated the provider response, and internalUserId came from a verified identity-linking step. The storage interface can sit on Postgres or another transactional store; the security property comes from the lookup and state transition, not the brand of database.
This example targets Node.js 20 or later and sets an eight-hour absolute lifetime. That number is an example policy, not a universal security constant; a real team should choose it from the risk of the actions available during the session and the amount of repeated sign-in friction its users can tolerate.
import { createHash, randomBytes } from "node:crypto";
type Session = {
tokenHash: string;
userId: string;
createdAt: Date;
expiresAt: Date;
revokedAt: Date | null;
};
interface SessionStore {
insert(session: Session): Promise<void>;
findActive(tokenHash: string, now: Date): Promise<Session | null>;
revoke(tokenHash: string, now: Date): Promise<boolean>;
revokeAllForUser(userId: string, now: Date): Promise<number>;
}
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
function hashToken(token: string): string {
return createHash("sha256").update(token, "utf8").digest("hex");
}
export async function createSession(
store: SessionStore,
internalUserId: string,
now = new Date(),
): Promise<{ cookie: string; expiresAt: Date }> {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(now.getTime() + SESSION_TTL_MS);
await store.insert({
tokenHash: hashToken(token),
userId: internalUserId,
createdAt: now,
expiresAt,
revokedAt: null,
});
const maxAgeSeconds = Math.floor(SESSION_TTL_MS / 1000);
const cookie = [
`__Host-session=${token}`,
"Path=/",
"HttpOnly",
"Secure",
"SameSite=Lax",
`Max-Age=${maxAgeSeconds}`,
].join("; ");
return { cookie, expiresAt };
}
export async function authenticate(
store: SessionStore,
presentedToken: string | undefined,
now = new Date(),
): Promise<string | null> {
if (!presentedToken) return null;
const session = await store.findActive(hashToken(presentedToken), now);
return session?.userId ?? null;
}
Thirty-two random bytes provide 256 bits before encoding. The database never needs the raw bearer token, so a read-only leak of the session table does not directly yield reusable cookies. Hashing does not rescue a weak token, though; unpredictability comes from a cryptographically secure random generator.
The query behind findActive must make validity explicit: matching hash, revoked_at IS NULL, and expires_at > now. Hiding one of those tests in occasional cleanup creates a gap where an expired record may still authenticate. Cleanup is housekeeping. Validation is enforcement.
Why isn't cookie deletion enough?
Deleting the cookie logs out the browser that received the deletion response. It does nothing to a copied value on another device, in a proxy log, or in an attacker-controlled client. Server-side revocation changes the authority itself, so every later lookup rejects the credential.
Consider a concrete flow. A user signs in through Google on a laptop and through GitHub on a phone. Both external identities resolve to one internal account, but the application creates two session records, S1 and S2. The user loses the phone. Revoking S2 preserves the laptop session and removes access from the missing device. If support instead confirms an account takeover or the user completes a high-risk recovery, revoking every session for the internal user is the safer boundary. Now add the awkward case: the phone submits a wire request at nearly the same moment the user revokes it from the laptop. The application cannot honestly promise that an action already authorized and committed will be undone by later revocation. It can promise that every request whose security decision occurs after the revocation transaction observes a dead session. That promise needs a primary consistency boundary or another carefully specified ordering rule; an eventually updated cache cannot invent it. The audit trail should preserve both timestamps and the internal session IDs so an operator can tell which decision won without exposing either bearer token.
Do not revoke by provider email alone. OpenID Connect defines the stable identifier as the combination of issuer and subject, and OAuth authorization responses require defenses such as exact redirect URI matching and CSRF protection. Email can change, be absent, or represent a different assurance level. Resolve the verified external identity to the internal user first; then apply revocation to internal session IDs. My decision rule is conservative here: identity establishes account ownership, while session state controls current access. Mixing those jobs makes both harder to audit.
There is a hard trade-off here. User-wide revocation adds friction to every legitimate device. Session-only revocation leaves other devices untouched, which is convenient but insufficient when the account rather than one device is suspect. For a fintech flow, I would make the choice from the triggering event, not from a universal timeout: ordinary logout revokes one session; credential recovery, administrative lock, and suspicious identity relinking revoke all sessions. A transfer-risk engine can also demand fresh authentication without pretending that a long-lived session is fresh proof.
Where should revocation live?
Revocation belongs in the synchronous authentication path. An event bus may distribute audit data or invalidate caches, but the source of truth must produce a clear active-or-dead answer before protected business logic runs. Otherwise, a consumer delay becomes an authorization grace period.
The lookup is the lock.
For a single Node.js service and Postgres, a direct indexed lookup is often the honest starting point. At higher request volume, a cache can reduce database load, but cached acceptance is the dangerous direction: a five-minute positive cache can turn immediate revocation into five-minute revocation. Either invalidate it reliably on the revocation transaction or use a deliberately short bound that the risk owner has accepted. Measure the lookup before adding that complexity.
Signed self-contained tokens shift the cost profile. They can be verified without a shared read, but an unexpired token remains usable unless the system also checks a denylist, a per-user cutoff, or another online signal. Once immediate logout is required, the design has regained state somewhere. This is not a reason to reject signed tokens; it is a reason to describe their revocation semantics precisely.
Race conditions deserve attention too. The revocation update and a concurrent wire request can arrive together. The session check should occur inside the request's security decision, and highly sensitive actions should combine session validity with transaction authorization, idempotency, limits, and fresh-auth rules. Session management is one layer. It cannot carry the entire fraud model.
Operating the boundary without punishing every user
Start with separate lifetimes: an absolute expiry limits total exposure, while an idle policy limits abandoned sessions. Avoid silently extending a session forever. For sensitive actions, record when the user last authenticated and require a new provider or local authentication ceremony when that proof is too old for the action's risk. OWASP recommends reauthentication after high-risk events and session invalidation with token rotation after reauthentication.
Instrument decisions, not secrets. An audit event can include an internal session ID, internal user ID, event type, timestamp, coarse client context, and reason code. Never log the raw cookie. Useful reason codes distinguish voluntary logout, expiry, recovery, administrator action, and suspicious identity change; that lets operators explain why access stopped without reconstructing it from generic request logs.
Test the ugly paths. A copied cookie must fail after logout. Two sessions must allow targeted revocation. User-wide revocation must cover sessions created through both social providers. An expired record must fail even before cleanup. Parallel revoke-and-transfer tests should establish the application's chosen ordering, while callback tests cover state/nonce checks, redirect URI validation, and issuer/subject identity mapping.
Before shipping, I would trace one login all the way through callback verification, internal identity resolution, token creation, cookie delivery, authenticated lookup, rotation, and revocation. Then I would inspect logs for token leakage, verify that replicas or caches cannot extend the promised revocation window, and rehearse user-wide invalidation. The rule is uncomplicated: keep the low-friction session while its server-side record remains trustworthy; remove that trust decisively when the risk changes.
Sources
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- https://openid.net/specs/openid-connect-core-1_0.html
- https://www.rfc-editor.org/rfc/rfc9700.html
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
Top comments (0)