← Complete Projects

阅读中文版本

Build a Cookie Session Store

A server-side session store that issues signed, http-only cookies, rotates the session id on privilege change, and expires idle sessions with a background sweeper.

intermediate~4 hsessioncookiesecuritynode

Project goal

Issue a tamper-evident session cookie, store the session data server-side keyed by the cookie id, rotate the id on privilege change to prevent fixation, and expire idle sessions to bound the store size.

Final features

  • Signed http-only cookie
  • Server-side session data
  • Id rotation on privilege change
  • Idle expiry sweeper
  • CSRF token per session

Architecture

A session store maps a random id to a session record with data, last-seen, and a CSRF token. The cookie carries the id signed with an HMAC; the server verifies the signature, loads the record, updates last-seen, and re-signs on rotation. A sweeper deletes records whose last-seen is older than the idle timeout.

Prerequisites

  • Node.js 20 LTS or later
  • A persistent store for session records (Redis or a database)

Dependencies

  • ioredis ^5.4.0 — Session record store
  • node:crypto built-in — HMAC cookie signing and id generation

Directory structure

cookie-session-store/
├── src/store.ts
├── src/cookie.ts
├── src/sweeper.ts
├── tests/session.test.ts
└── package.json

Phases

1. Issue and verify

On login, create a random id, store the record, and set a signed http-only cookie; on request, verify the signature and load the record.

const id = randomBytes(32).toString("hex"); const sig = hmac(id); res.cookie("sid", id+"."+sig, {httpOnly:true});

Expected behavior: A bad signature rejects the cookie and treats the request as unauthenticated.

2. Rotate on privilege change

When the session gains or loses privilege, generate a new id, move the record, and re-sign the cookie.

const nid = randomBytes(32).toString("hex"); await move(id, nid); res.cookie("sid", nid+"."+hmac(nid));

Expected behavior: Rotation prevents a fixed id from carrying elevated privilege.

3. Idle expiry sweeper

A background job deletes session records whose last-seen is older than the idle timeout.

await del(id) where record.lastSeen < now - idleMs;

Expected behavior: The sweeper bounds the store size so inactive sessions do not accumulate.

4. CSRF token

Generate a per-session CSRF token, store it in the record, and require it on state-changing requests.

const csrf = randomBytes(32).toString("hex"); record.csrf = csrf;

Expected behavior: The token is bound to the session id, so rotation invalidates the old token.

Testing

Log in, assert a signed cookie is set; tamper the cookie and assert the server rejects it; elevate privilege and assert the id rotates; wait past the idle timeout and assert the sweeper deletes the record; send a state-changing request without the CSRF token and assert rejection.

Troubleshooting

Sessions expire too fast

The idle timeout is shorter than the usage pattern; lengthen it.

Cookie rejected on subdomain

The cookie domain is too narrow; set it to the parent domain.

Deployment

Run the sweeper as a singleton in one process to avoid concurrent deletes; set the cookie to secure and same-site strict in production.

Known limitations

  • The store is external; a store outage logs everyone out, because the cookie id cannot be verified without the record.
  • No sliding window per request; last-seen updates on every request, which is a write per request.
  • The CSRF token is per-session, not per-request; double-submit is out of scope.

Project overview

A session cookie that carries the session data in the clear is a liability, because a cookie is user-controlled and a tampered session can elevate privilege. A server-side store keeps the data on the server and the cookie carries only an id, signed so it cannot be forged. The server verifies the signature, loads the data, and the cookie is a reference, not a payload.

The id rotation on privilege change is what prevents session fixation. An attacker who fixes a known id before login expects to use that id after the victim authenticates. If the server rotates the id on login, the fixed id is discarded and the attacker holds a reference to nothing.

The idle expiry sweeper is what bounds the store size. A session store that never expires grows forever, because every visitor leaves a record. A sweeper that deletes records older than the idle timeout keeps the store bounded and reclaims space from sessions that are abandoned, not logged out.

The CSRF token is what prevents cross-site state changes. A cookie is sent automatically on every request to the cookie domain, including requests forged by another site. A CSRF token that the attacker cannot read and must submit on state-changing requests breaks the forged request, because the attacker does not have the token.

The value of this project is the signing and the rotation, not the store. A key-value store is trivial; signing a cookie so it cannot be forged and rotating it so it cannot be fixed are the parts worth learning, because those are the guarantees that make a cookie session safe to use.

What you will build

The project delivers five capabilities, each driven by a test:

  • Signed http-only cookie
  • Server-side session data
  • Id rotation on privilege change
  • Idle expiry sweeper
  • CSRF token per session

A session store maps a random id to a session record with data, last-seen, and a CSRF token. The cookie carries the id signed with an HMAC; the server verifies the signature, loads the record, updates last-seen, and re-signs on rotation. A sweeper deletes records whose last-seen is older than the idle timeout.

Architecture and data model

The session id is 32 random bytes hex-encoded, 64 characters. The signature is an HMAC of the id with a server secret, and the cookie value is the id and the signature joined by a dot. The server splits the cookie, recomputes the HMAC, and rejects on mismatch in constant time to avoid a timing oracle.

The session record stores the data, the last-seen timestamp, and the CSRF token. The data is whatever the application needs, serialized as JSON. The last-seen is updated on every request, which is a write per request, a trade-off for the sliding expiry. A stricter design updates last-seen only on a schedule, but that loses precision.

The rotation on privilege change moves the record to a new id and deletes the old one. The cookie is re-signed with the new id, so the old cookie is invalid immediately. The rotation is atomic from the store perspective: the new record is written before the old is deleted, so a concurrent request that reads the old id still finds the record.

The sweeper runs on a schedule, scans for records older than the idle timeout, and deletes them. Running the sweeper in one process avoids concurrent deletes that waste work. The scan is a range query on the last-seen index, so it is cheap and does not scan the whole store.

The CSRF token is generated per session and stored in the record. A state-changing request must submit the token in a header or a body field, and the server compares it to the record in constant time. The token rotates with the session id, because a new id means a new record and a new token.

Prerequisites and setup

You need the following before you begin:

  • Node.js 20 LTS or later
  • A persistent store for session records (Redis or a database)

The dependencies cover the core needs:

  • ioredis (^5.4.0) — Session record store
  • node:crypto (built-in) — HMAC cookie signing and id generation
pnpm init
pnpm add ioredis
pnpm add -D typescript tsx vitest

Keep secrets and configuration in the environment, and validate them at boot so a misconfigured service fails loudly instead of silently dropping work.

Directory structure

cookie-session-store/ is organized so each concern is testable in isolation:

cookie-session-store/
├── src/store.ts
├── src/cookie.ts
├── src/sweeper.ts
├── tests/session.test.ts
└── package.json

Phase 1: Issue and verify

On login, create a random id, store the record, and set a signed http-only cookie; on request, verify the signature and load the record.

const id = randomBytes(32).toString("hex"); const sig = hmac(id); res.cookie("sid", id+"."+sig, {httpOnly:true});

A bad signature rejects the cookie and treats the request as unauthenticated.

The phase is driven by a test that asserts the expected behavior, so the implementation is correct before the next phase builds on it.

Phase 2: Rotate on privilege change

When the session gains or loses privilege, generate a new id, move the record, and re-sign the cookie.

const nid = randomBytes(32).toString("hex"); await move(id, nid); res.cookie("sid", nid+"."+hmac(nid));

Rotation prevents a fixed id from carrying elevated privilege.

The phase is driven by a test that asserts the expected behavior, so the implementation is correct before the next phase builds on it.

Phase 3: Idle expiry sweeper

A background job deletes session records whose last-seen is older than the idle timeout.

await del(id) where record.lastSeen < now - idleMs;

The sweeper bounds the store size so inactive sessions do not accumulate.

The phase is driven by a test that asserts the expected behavior, so the implementation is correct before the next phase builds on it.

Phase 4: CSRF token

Generate a per-session CSRF token, store it in the record, and require it on state-changing requests.

const csrf = randomBytes(32).toString("hex"); record.csrf = csrf;

The token is bound to the session id, so rotation invalidates the old token.

The phase is driven by a test that asserts the expected behavior, so the implementation is correct before the next phase builds on it.

Testing

Log in, assert a signed cookie is set; tamper the cookie and assert the server rejects it; elevate privilege and assert the id rotates; wait past the idle timeout and assert the sweeper deletes the record; send a state-changing request without the CSRF token and assert rejection.

The login test creates a session and asserts the cookie is set with http-only, secure, and same-site strict. The tamper test modifies the cookie signature and asserts the server rejects it and treats the request as unauthenticated, proving the HMAC is verified.

The rotation test logs in, then elevates privilege, and asserts the cookie id changes. The old id, submitted in a subsequent request, is rejected, proving the rotation invalidated the fixed id. The CSRF test sends a state-changing request without the token and asserts rejection, then with the token and asserts success.

The sweeper test creates a session, advances the clock past the idle timeout, runs the sweeper, and asserts the record is deleted. A session that was seen recently is not deleted, proving the sweeper respects the last-seen.

The constant-time test measures the verification time for a valid and an invalid signature and asserts they are close, because a timing difference leaks the signature. The concurrency test sends two requests with the old and new id during a rotation and asserts at least one succeeds, proving the rotation is atomic.

The fixation test sets a known id before login, logs in, and asserts the post-login cookie id differs, proving the rotation defeated the fixation.

pnpm vitest run

Observability

Track the signed http-only cookie rate, the session resource usage, and the error count as the three primary signals for the cookie-session-store system. A sudden change in any of these is the signal that something has shifted, not necessarily broken but worth a look before it becomes an incident. Chart the server-side session data alongside the signed http-only cookie so a correlation is visible at a glance, because a rising server-side session data with a stable signed http-only cookie means the system is doing more work per unit, not that it is failing.

Log every issue and verify transition with the context that triggered it, because an operator who sees the symptom but not the cause will guess wrong. The log entry should carry the session identifier, the input that caused the transition, and the outcome, so the operator can reconstruct what happened without re-running it. A log without context is a line of text; a log with context is a diagnosis.

Set alerts on the derived metrics, not the raw counters. A rate of change is more actionable than a total, because a total grows monotonically and never tells you when the growth accelerated. Alert on the signed http-only cookie error rate crossing a threshold, the session utilization exceeding a percentage, and the server-side session data latency exceeding the SLO. Each alert should have a runbook link, so the operator who receives it at three in the morning knows what to do, not just what is wrong.

The cookie-session-store system should expose its metrics in a format the existing dashboard can scrape, so the observability is additive, not a new tool. A metric that nobody reads is noise, so before adding one, confirm it has a consumer. The dashboard has three panels: throughput, error rate, and resource utilization, each with a threshold line that is the SLO, so a glance tells an operator whether the system is healthy without reading numbers.

Deployment and operations

Run the sweeper as a singleton in one process, because concurrent sweepers delete the same records and waste work. A leader election or a simple flag picks the sweeper process, and the others skip it. The store is the source of truth, so the sweeper can run in any process that can reach it.

Set the cookie to secure in production, so it is only sent over HTTPS. Set same-site to strict, so it is not sent on cross-site requests, which is a CSRF defense at the cookie level. Set the domain to the parent domain if the app spans subdomains, or omit it to restrict to the issuing host.

Keep the signing secret in the environment, not the repository, and rotate it periodically. A leaked secret lets an attacker forge any session id. Rotating the secret invalidates all cookies, which is a forced logout, so plan it for low traffic.

Store the session records in Redis or a database with a TTL on each record, so the store expires sessions even if the sweeper is down. The sweeper is a belt-and-suspenders with the TTL, not a replacement, because the TTL is a per-key timeout and the sweeper is a batch scan.

Security considerations

Treat every input to the cookie-session-store system as untrusted, because the signed http-only cookie path is the one place an external caller controls what the system processes. Validate the input shape before any session operation, and reject early with a clear error rather than letting a malformed input propagate into the issue and verify phase where it causes a confusing failure that is hard to trace back to its source.

Keep secrets and configuration in the environment, never in the repository. The ioredis credentials, the signing keys, and the service endpoints are all environment variables, read once at boot and validated, so a misconfigured cookie-session-store fails loudly at startup instead of silently misbehaving at runtime. A system that starts with a bad configuration and runs anyway is a system that will produce wrong results for hours before anyone notices.

Rate-limit the signed http-only cookie endpoint per caller, because an unbounded session operation is a resource leak as much as a security problem. A caller that can trigger the rotate on privilege change path without limit can exhaust the system, whether intentionally or not. The rate limit is a per-caller ceiling that costs legitimate callers nothing and stops the abuse before it reaches the issue and verify logic.

Audit-log every state-changing operation with the caller identity, the input, and the timestamp. The audit log is not for debugging, it is for accountability: if something went wrong, the log says who did what and when. The cookie-session-store system writes the audit log to an append-only store that the system itself cannot modify, so a compromised system cannot rewrite its history.

Do not expose internal errors to the caller. A stack trace is a map of the system internals, and an attacker who collects them learns the session structure. Return a generic error with a correlation id, and log the full error internally with the same id, so an operator can trace it without leaking it.

Limitations and next steps

  • The store is external; a store outage logs everyone out, because the cookie id cannot be verified without the record.
  • No sliding window per request; last-seen updates on every request, which is a write per request.
  • The CSRF token is per-session, not per-request; double-submit is out of scope.

The store is external. A store outage logs everyone out, because the cookie id cannot be verified without the record. An in-memory store avoids this but loses sessions on restart and does not scale across processes, so the external store is the right choice despite the outage risk.

There is no sliding window per request. The last-seen is updated on every request, which is a write per request to the store. A design that updates last-seen on a schedule, such as every five minutes, reduces writes but loses expiry precision, and that trade-off is not made here.

The CSRF token is per-session, not per-request. A per-request token, the double-submit pattern, is stronger because a stolen token expires with the request, but it is more complex and out of scope. The per-session token is the common case and is correct against the standard CSRF attack.