Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

Signed-Cookie Sessions Without a Login (HMAC in About 40 Lines)

Not every app needs a login. Sometimes you just want each visitor to have a stable handle so a leaderboard, a draft, or a rate limiter can tell them apart. Building full auth for that is overkill. But you also cannot jus

Not every app needs a login. Sometimes you just want each visitor to have a stable handle so a leaderboard, a draft, or a rate limiter can tell them apart. Building full auth for that is overkill. But you also cannot just trust a plain nickname cookie, because the browser owns that cookie and anyone can edit it.

The middle ground is a signed cookie session: you put a small piece of identity in a cookie, sign it with an HMAC using a server-only secret, and verify the signature on every request. The client can read the cookie, but it cannot forge a valid one. I use exactly this in injection-arena, a prompt-injection game where players need a persistent identity for the leaderboard but I never wanted to run an accounts system. Let me walk through the technique.

The shape of the thing

A signed cookie value is two parts glued together:

base64url(payload) . signature

The payload is your identity data as JSON. The signature is HMAC-SHA256(payload, secret). Because the secret never leaves the server, only the server can produce a signature that matches a given payload. If an attacker edits the payload, the signature no longer verifies and you reject it.

That is the whole idea. Now the code.

Signing and encoding

import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";

export interface Session {
  sessionId: string;
  nickname: string;
}

const SECRET = process.env.SESSION_SECRET || "dev-insecure-secret-change-me";
export const COOKIE_NAME = "arena_session";

function sign(payload: string): string {
  return createHmac("sha256", SECRET).update(payload).digest("base64url");
}

// Encodes a session into a signed cookie value: base64(payload).sig
export function encodeSession(session: Session): string {
  const payload = Buffer.from(JSON.stringify(session)).toString("base64url");
  return `${payload}.${sign(payload)}`;
}

base64url matters here. Regular base64 uses +, /, and =, which are not friendly inside a cookie value. The url-safe variant sticks to characters that survive transport untouched, and it is built into Node's Buffer and digest, so there is nothing extra to install.

Verifying and decoding

Verification is where the security actually lives, so read this part slowly:

export function decodeSession(value: string | undefined): Session | null {
  if (!value) return null;
  const dot = value.lastIndexOf(".");
  if (dot < 0) return null;
  const payload = value.slice(0, dot);
  const sig = value.slice(dot + 1);

  const expected = sign(payload);
  const a = Buffer.from(sig);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;

  try {
    const parsed = JSON.parse(Buffer.from(payload, "base64url").toString());
    if (typeof parsed?.sessionId === "string" && typeof parsed?.nickname === "string") {
      return parsed as Session;
    }
    return null;
  } catch {
    return null;
  }
}

Three details I want to call out.

First, I recompute the signature over the payload I actually received and compare it to what came in. I never trust the incoming signature on its own.

Second, I use timingSafeEqual instead of ===. A normal string compare bails out at the first mismatched byte, and the tiny timing difference can, in theory, leak the correct signature one character at a time. A constant-time compare removes that side channel. I guard the length first because timingSafeEqual throws when the buffers differ in length.

Third, even after the signature checks out, I still validate the decoded shape. Signature valid means the payload came from me unchanged, but a defensive typeof check keeps a future code change or a rolled secret from handing back a half-formed object.

Minting a session

New identities get a random id so two players who pick the same nickname stay distinct, and nicknames get sanitized because they are user input that will show up on a leaderboard:

export function createSession(nickname: string): Session {
  const clean = sanitizeNickname(nickname);
  return { sessionId: randomBytes(12).toString("hex"), nickname: clean };
}

export function sanitizeNickname(nickname: string): string {
  const trimmed = (nickname || "").trim().slice(0, 24);
  const safe = trimmed.replace(/[^\w \-.]/g, "");
  return safe || `anon-${randomBytes(3).toString("hex")}`;
}

Wiring it to real cookies

In a Next.js route handler the read and write are small. The cookie flags are the part people skip, so do not skip them:

import { cookies } from "next/headers";

export function writeSession(session: Session): void {
  cookies().set(COOKIE_NAME, encodeSession(session), {
    httpOnly: true,   // JavaScript in the browser cannot read it
    sameSite: "lax",  // not sent on cross-site requests
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
  });
}

export function readSession(): Session | null {
  const raw = cookies().get(COOKIE_NAME)?.value;
  return decodeSession(raw);
}

httpOnly keeps client scripts from reading the cookie, which shrinks the blast radius of an XSS bug. sameSite: "lax" keeps it off cross-site requests. In production you also want secure: true so it only travels over HTTPS.

When this is the right tool

Reach for signed cookies when the identity is low-stakes and self-asserted: a leaderboard handle, an anonymous poll, a "continue where you left off" draft, per-visitor rate limiting, or a wizard that spans a few pages. You get zero database rows, zero session store, and an identity that survives refreshes and tabs, all from a secret and forty lines of code.

The one honest caveat

This is identity-lite, not authentication. A signed cookie proves the payload was not tampered with. It does not prove the person holding it is who they claim to be, because nobody ever proved anything in the first place. There is no password, no email, no second factor. Anyone who copies the cookie string becomes that user, and a player can throw their own cookie away and mint a brand new identity whenever they like. That is completely fine for a game leaderboard and completely wrong for anything guarding real data, money, or private records. The moment "who is this really" matters, you need actual auth. And rotate your SESSION_SECRET with care: changing it invalidates every existing cookie at once, which for anonymous sessions just means everyone starts fresh.

If you want to see the whole thing running inside a real app, with the session feeding a live leaderboard, the full source is at github.com/AgentPostmortem/injection-arena. Clone it, read lib/session.ts, and adapt it to whatever small thing needs to remember its visitors.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.