Skip to content

Signed & Encrypted Cookies

pidgn provides two cookie helpers for storing user-facing data that must not be trivially modified by the client:

  • SignedCookies — the value is visible client-side, but any modification invalidates the HMAC and the cookie is rejected on read. Use for non-secret identifiers like user IDs, preference flags, feature toggles.
  • EncryptedCookies — the value is both opaque and tamper-evident, using AES-256-GCM with a fresh random nonce per cookie. Use when the client should not see the value (session tokens, cart state containing prices, etc.).

Both are comptime-bound to a secret or key — the helpers return a namespace struct parameterised on the configuration, so there’s no runtime secret lookup on every call.

SignedCookies takes a SignedCookieConfig:

OptionTypeDefaultDescription
secret[]const u8(required)HMAC secret. Compile error if empty. Use at least 32 random bytes.

EncryptedCookies takes an EncryptedCookieConfig:

OptionTypeDefaultDescription
key[32]u8(required)Raw 32-byte AES-256 key.
  1. Bind a helper at comptime, typically near your router definition.

    const SignedCookies = pidgn.SignedCookies(.{
    .secret = "change-me-to-32-random-bytes",
    });
  2. Set a cookie from a handler:

    fn login(ctx: *pidgn.Context) !void {
    // ... authenticate ...
    SignedCookies.set(ctx, "uid", "42", .{
    .max_age = 86_400,
    .http_only = true,
    .secure = true,
    .same_site = .lax,
    });
    ctx.redirect("/", .see_other);
    }
  3. Read it back on a later request — a tampered or missing cookie returns null:

    fn profile(ctx: *pidgn.Context) !void {
    const uid = SignedCookies.get(ctx, "uid") orelse {
    ctx.redirect("/login", .see_other);
    return;
    };
    // ... use uid
    }

Same shape — construct with a 32-byte key:

// 32 bytes sourced from your secrets store, NOT hardcoded in production.
const key: [32]u8 = ... ;
const EncryptedCookies = pidgn.EncryptedCookies(.{ .key = key });
// Set:
EncryptedCookies.set(ctx, "cart", cart_json, .{ .max_age = 3600 });
// Read — owned by the caller; free when done.
if (EncryptedCookies.get(ctx, "cart")) |cart| {
defer ctx.allocator.free(cart);
// ...
}

Signed cookies’ get returns a borrowed slice into the request’s Cookie header — do not free. Encrypted cookies return an allocated slice because decryption needs fresh memory.

Signed: <value>.<url-safe-base64(HMAC-SHA256(value))>

Encrypted: url-safe-base64(nonce(12) || ciphertext || tag(16)) — standard AES-256-GCM framing.

Neither form is secret about its format, so you can inspect signed cookies in the browser devtools and confirm they contain what you think they do.

If you…Use
Store a user ID or boolean flag the client may seeSignedCookies
Store anything the client should not see (pricing, tokens, PII)EncryptedCookies
Store data >4 KBNeither — put it in a session-backed store and reference it from a short cookie
Need both opacity and server-side invalidationUse a plain session cookie referencing a server-side record

Put the secret / key in an environment variable and load it at startup. pidgn.env.require("COOKIE_SECRET") will fail fast if the secret is missing, which is almost always what you want.

const secret = pidgn.env.require("COOKIE_SECRET"); // []const u8
const SignedCookies = pidgn.SignedCookies(.{ .secret = secret });

Rotating a secret invalidates every cookie that was signed with the old one. That’s fine for signed cookies (users re-authenticate) but painful for encrypted cookies holding long-lived state — plan for a key-rotation strategy if that applies to you.