Skip to Content

Signed and sealed values

Tamper-proof and encrypted values for cookies, links and webhooks.

Sometimes you hand a value to a client and need it back unchanged: a session cookie, a link in an email, the state of an OAuth redirect. Two tools cover it.

createSignercreateSealer
The client can read the valueYesNo
The client can change the valueNoNo
Can expireNo, add your own timestampYes, with ttl
HoldsA stringAny JSON value
MethodHMAC-SHA-256AES-256-GCM

Use a signer when the content is not sensitive. Use a sealer when it is, or when you want expiry built in.

Signing

import sukuCrypto from '@sukujs/crypto';

const signer = sukuCrypto.createSigner(env.COOKIE_SECRET);

const packed = await signer.pack('user.42');      // "user.42.3kV9...": value plus signature
await signer.unpack(packed);                      // "user.42"
await signer.unpack(packed.replace('42', '1'));   // undefined

unpack returns the original value, or undefined if anything was altered. It never throws.

For data that travels separately from its signature, such as a webhook body and a header, use sign and verify.

const signature = await signer.sign(body);
await signer.verify(body, signature);   // true or false, compared in constant time

Signatures are base64url. For a provider that sends hex, see verifying webhooks.

Sealing

import sukuCrypto from '@sukujs/crypto';

const sealer = sukuCrypto.createSealer(env.APP_SECRET, { purpose: 'session' });

const sealed = await sealer.seal({ userId: 42, roles: ['admin'] }, { ttl: '7d' });
// "v1.pX0c...": unreadable and URL-safe

await sealer.unseal<{ userId: number; roles: string[] }>(sealed);   // the object, or undefined

unseal returns undefined if the value was altered, has expired, was sealed with another secret, or was sealed for another purpose.

Each call to seal uses a fresh random nonce, so sealing the same value twice gives two different strings. Nothing about the content leaks from the output, except its approximate length.

Purpose

purpose ties a value to a single use. Without it, a value sealed for one feature could be replayed against another that shares the secret.

const reset = sukuCrypto.createSealer(env.APP_SECRET, { purpose: 'password-reset' });
const invite = sukuCrypto.createSealer(env.APP_SECRET, { purpose: 'team-invite' });

await invite.unseal(await reset.seal({ userId: 42 }));   // undefined

Give every use its own purpose. It costs nothing and removes a whole class of mistake.

Expiry

ttl is enforced when unsealing. An expired value is indistinguishable from an invalid one.

await sealer.seal(payload, { ttl: '30m' });

A sealed value cannot be revoked before it expires. For anything that must be single-use, also record it server-side. See password reset links.

Cookies

route.post('/session', {
  body: Login,
  status: 200,
  handle: async ({ body, ctx }) => {
    const user = await authenticate(body);
    ctx.cookies.set('session', await sealer.seal({ userId: user.id }, { ttl: '7d' }), { maxAge: 604_800 });
    return { ok: true };
  },
});

route.get('/me', {
  handle: async ({ ctx }) => {
    const session = await sealer.unseal<{ userId: string }>(ctx.cookies.get('session') ?? '');
    if (!session) throw new UnauthorizedException();
    return users.find(session.userId);
  },
});

ctx.cookies.set defaults to HttpOnly, Secure and SameSite=Lax. Keep the cookie's maxAge in step with the ttl. See handlers for the options.

Rotating the secret

sukuCrypto.createSigner(env.COOKIE_SECRET, { previousSecrets: [env.COOKIE_SECRET_OLD] });
sukuCrypto.createSealer(env.APP_SECRET, { purpose: 'session', previousSecrets: [env.APP_SECRET_OLD] });

New values use the current secret. Old values keep working until you remove the previous one.

Size

A sealed value is about a third larger than its JSON, plus roughly 50 characters. Browsers cap a cookie near 4,096 bytes, so store identifiers, not whole records.

On this page