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.
createSigner | createSealer | |
|---|---|---|
| The client can read the value | Yes | No |
| The client can change the value | No | No |
| Can expire | No, add your own timestamp | Yes, with ttl |
| Holds | A string | Any JSON value |
| Method | HMAC-SHA-256 | AES-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')); // undefinedunpack 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 timeSignatures 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 undefinedunseal 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 })); // undefinedGive 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.