Signing Cookies And Building Real Sessions
Signing a cookie doesn't hide the value, it proves nobody changed it. Combined with a random session ID stored server-side, that's what a real login system actually looks like.
The last post got HttpOnly cookies and the idea of a session on the table. Neither one stops someone from editing a cookie's value and hoping the server just accepts it. That's what signing fixes, and it's a lot simpler than "cryptography" makes it sound.
Signing Is Not Encryption
Encrypting a value hides it. Signing a value does something different: it proves the value hasn't changed since the server produced it, while leaving the value itself fully readable.
The mental model that finally made this click for me: take the cookie's value and a secret only the server knows, mix them together, and the result is a signature that could only have come from someone holding that exact secret.
signature = mix(value, secret)Change the value without knowing the secret, and there's no way to produce the matching signature. The server just redoes the mixing on its end, compares the result to what arrived, and rejects anything that doesn't match.
ExpandValue plus secret mixed into a signature, shipped alongside the plain-text value, and a tamper attempt that fails because the new signature never matches.
In Express, cookie-parser handles this for you:
app.use(cookieParser("this-should-not-be-hardcoded"));
res.cookie("sid", sessionId, { httpOnly: true, signed: true });
// later
const sid = req.signedCookies.sid;Try editing sid in DevTools now, and the signature check fails on the very next request. The value is still sitting there in plain text if you look at it. What changed is that changing it no longer works.
Reaching for cookie-parser instead of hand-rolling the signing logic is the right instinct, and it generalizes. Don't write your own database driver, don't write your own crypto, and be careful reaching for your own sanitizer either. A widely used library has had far more eyes on its edge cases than a function written in an afternoon ever will. That cuts both ways, though: Express used to ship csurf, its own answer to CSRF protection, and it eventually got deprecated because the library itself turned out to have security holes. Popular is not automatically safe. It's worth checking whether a library is still maintained before trusting it with anything security-related, not just whether it's popular.
Where the Secret Actually Belongs
That secret string cannot live as a hardcoded value in your source code, and this is worth being direct about: if that code ever gets pushed to a repository, everyone with access to the repo has your secret. Everyone with a copy of the code from before an exploit was caught has it too. A disgruntled former employee has it as long as they kept a clone around.
The minimum bar is an environment variable, different per environment, so staging and production never share one. Beyond that, teams running distributed systems across many servers move to a proper secrets manager, something built to store and rotate credentials without anyone needing to know the actual value. That's a step past what a single small app usually needs, but it's the direction things go as infrastructure grows.
Signing Alone Still Isn't a Real Session
Signing stops tampering, but the username in that example cookie is still sitting there in plain text, still readable by anyone who looks. A signed cookie proves the value came from the server unmodified. It says nothing about whether that value should have been exposed to the client in the first place.
That's the gap a real session closes.
Building a Real Session
The idea from two posts back gets implemented here for real: instead of putting identity in the cookie, put a random, meaningless identifier there, and keep the actual identity mapping on the server.
Node's built-in crypto module generates something suitable for this:
const crypto = require("crypto");
function generateSessionId() {
return crypto.randomBytes(16).toString("hex");
}Sixteen random bytes as hex gives enough entropy that guessing a valid session ID is not a realistic attack. Collisions matter here too. Hand two different users the same session ID by accident, and that's a security incident with your name on the postmortem, so lean on a proper random source rather than anything home-grown.
On login, the flow looks like this:
app.post("/login", async (req, res) => {
const user = await findUser(req.body.username, req.body.password);
if (!user) return res.status(401).send("Invalid credentials");
const sessionId = generateSessionId();
await db.run(
"INSERT INTO sessions (id, username) VALUES (?, ?)",
sessionId,
user.username
);
res.cookie("sid", sessionId, {
httpOnly: true,
signed: true,
secure: process.env.NODE_ENV === "production",
});
res.redirect("/profile");
});Every route that needs to know who's logged in does the reverse lookup:
app.get("/profile", async (req, res) => {
const sessionId = req.signedCookies.sid;
if (!sessionId) return res.redirect("/login");
const session = await db.get(
"SELECT username FROM sessions WHERE id = ?",
sessionId
);
if (!session) return res.redirect("/login");
res.send(`Welcome back, ${session.username}`);
});The client never sees a username again after login. All it ever holds is an opaque ID that means nothing outside the server's own session table.
What This Actually Buys You
Stacking these together, tampering fails because of the signature, and even a successfully guessed or intercepted session ID reveals nothing on its own without the server-side mapping. A mistake three layers up the stack, the kind that happens at three in the morning during an incident, doesn't automatically mean the whole system falls over.
It also brings back the benefit from two posts ago in a form that's actually implementable: kill one session by deleting one row, and every other device stays logged in exactly as before. No password reset required, no disruption to sessions that were never compromised.
The trade-off is the one already flagged: a session in a database is a read on every authenticated request and a write on every login. Redis is a common middle ground here, since it supports expiring keys natively, which removes the need for a separate cleanup job to prune old sessions.
What This Sets Up
Everything up to this point protects the cookie itself, its readability, its integrity, its contents. None of it protects against a request coming from somewhere it shouldn't.
That's a different boundary entirely, and it's the last piece of the cookie story before this series moves on to the attacks that actually break it.
The Essentials
- Signing proves a value wasn't tampered with. It never hides the value itself. Encryption and signing solve different problems.
- The mental model is value plus secret, mixed into a signature the server can recompute and check.
- The signing secret belongs in an environment variable, never hardcoded, and moves to a secrets manager once infrastructure grows past a single server.
- Signing alone doesn't stop identity from leaking to the client. A signed cookie can still expose a plain-text username.
- A real session replaces identity in the cookie with a random, opaque ID, generated with something like Node's
cryptomodule, mapped to the real user only on the server. - In-memory sessions are simple but don't scale past one server. Database-backed sessions do, at the cost of real read and write overhead.
Next up: same-origin policy, the boundary that decides which requests even get to carry a cookie in the first place, and the reminder that no cookie attribute alone protects against malicious JavaScript running on your own page.
Further Reading and Watching
- HMAC explained | keyed hash message authentication code: a walkthrough of the exact mixing mechanism behind signed cookies and tokens.
- Node.js crypto.randomBytes documentation: the official reference for generating cryptographically strong random values for session IDs.
Keep reading