Optional: encrypting cookies

When you log in to a web app, the server needs to remember who you are. It does this by storing a small piece of data in your browser called a cookie — typically containing something like your user ID. Your browser sends this cookie back with every request, so the server knows it's you.

Signing vs encryption

Most frameworks — including React Router — sign the cookie using a technique called HMAC. Signing means the server can detect if someone tampered with the cookie (changed their user ID to someone else's, for example). That's important.

But signing doesn't hide the data. The cookie value is just base64-encoded — anyone can decode it and read what's inside. It's like sending a sealed envelope made of clear plastic. Nobody can swap the letter, but everyone can read it.

Encryption scrambles the data so that only the server (which has the secret key) can read it. Now the envelope is opaque.

Can detect tamperingHides the data
Signed (HMAC)YesNo
Encrypted (AES-GCM)YesYes

What Gista.js does

Right now, our cookie only stores a user.public_id — an opaque, random identifier that doesn't reveal anything useful even if someone reads it. So HMAC signing alone is sufficient.

If you later store sensitive data in the cookie (preferences, tokens, PII), you can add AES-256-GCM encryption on top. The crypto.ts module in the codebase has a ready-to-use implementation for this.

When to consider encryption

You probably don't need it if your cookie only stores opaque IDs. Consider adding it when:

  • You store session state that reveals app internals — like permission levels, feature flags, or internal roles — that you don't want users inspecting
  • Compliance requirements (GDPR, HIPAA) call for encryption at rest

How encryption works

The same COOKIE_SECRET serves two purposes — React Router uses it directly for HMAC signing, and the encryption layer derives a separate key from it, so the two are cryptographically independent.

  1. You set a secret — a long random string (COOKIE_SECRET), at least 32 characters
  2. A key is derived — using HKDF (a key derivation function), the secret is turned into a proper cryptographic key for encryption
  3. Encrypt — the server encrypts the data with AES-256-GCM and a fresh random IV (initialization vector — a one-time-use random value that ensures the same data encrypts differently every time)
  4. Sign — React Router then signs the encrypted cookie with HMAC
  5. Verify + Decrypt — when the cookie comes back, React Router verifies the signature first, then our code decrypts the contents

The entire process uses the Web Crypto API — a standard built into every JavaScript runtime (browsers, Node.js, Deno, Cloudflare Workers). No external libraries needed.

Design details

  • HKDF over PBKDF2 — HKDF is designed for deriving keys from secrets that are already high-entropy (like a random COOKIE_SECRET). PBKDF2 is designed for passwords, where you need slow hashing to resist brute force. Using PBKDF2 with 1 iteration is pointless; with many iterations it adds latency for no benefit.
  • Random IV per encryption — a 12-byte random value is generated for every encryption, so the same data produces different ciphertexts each time. This is what AES-GCM recommends.
  • base64url encoding — standard base64 uses +, /, and = characters that can cause issues in cookie values. base64url replaces them with -, _, and drops padding.
  • Size overhead — encryption adds 28 bytes (12 IV + 16 authentication tag), which is about 40 characters in base64url. Negligible for a cookie.

Is signing alone safe?

Yes — for most apps, it absolutely is. HMAC signing prevents anyone from forging or tampering with the cookie. That's the important part. The only thing signing doesn't do is hide the contents — but if all you're storing is an opaque random ID, there's nothing useful to hide. Signing is the industry standard, and it's what React Router (and most other frameworks) ships with out of the box. You're not cutting corners by relying on it.