Skip to content
TDACorpIDENTITYSign in

Sealed sessions

Encrypt a small payload into a cookie value, or sign a session cookie, with a rotating secret, a purpose and an expiry.

On this page

Some state has to live in the browser between requests: the in-flight login transaction, or a small session. A value in a cookie can be read and edited by the person who holds it unless you protect it. The sealed module does that protection with two pairs of functions. It is imported from the SDK's sealed subpath.

PairProtectsFormat
seal and unsealConfidentiality and integrity: the person cannot read or change the payload.A compact JWE using dir with A256GCM.
signSession and verifySessionIntegrity only: the person can read the payload, not change it.A compact JWT signed with HS256.

The relying-party routes use seal for the login transaction, so you rarely call it yourself for that. Use these functions for your own cookies.

Secrets and rotation#

A secret is a string of at least 32 bytes (not 32 characters, bytes). A shorter one is refused. The secret is not used directly: a key is derived from it and from the purpose, so one secret can serve several uses without a value sealed for one use opening under another.

Pass either a string or a map from a whole number to a secret, for rotation.

const secret = { 2: newSecret, 1: oldSecret }

With a map, new values are sealed with the highest number and carry that number as their kid. A value is opened with the secret its kid names, so old cookies keep working until you remove the old number. A value whose kid is not in the map is refused as unknown-key.

seal and unseal#

import { seal, unseal } from '@tdacorp/identity-sdk/sealed'const value = await seal({ step: 'checkout', cart: 'c_123' }, { secret, ttlSeconds: 600, purpose: 'my-app:checkout' })const payload = await unseal<{ step: string; cart: string }>(value, {  secret,  purpose: 'my-app:checkout',  onError: (reason) => log.warn({ reason }),})
FunctionBehaviour
seal(payload, { secret, ttlSeconds, purpose })Returns the sealed string. ttlSeconds is a positive whole number and becomes the expiry. Throws if the sealed value would exceed 4096 bytes.
unseal(value, { secret, purpose, onError?, now? })Returns the payload, or null if the value is invalid, expired or sealed under another purpose or secret. It does not throw for a bad value.

onError receives expired, invalid or unknown-key. Use it for logging. Treat all three the same when you answer the person, which is as if there were no cookie.

The 4096 byte limit#

A browser keeps about 4 KB per cookie, and splitting a value across cookies makes a half-edited value possible. So the limit is enforced when you seal. Keep the payload small: an identifier, not a record. Hold the rest on your server. Tokens do not fit; store them server side, keyed by your own session.

signSession and verifySession#

import { signSession, verifySession } from '@tdacorp/identity-sdk/sealed'const { value, maxAge } = await signSession({ sub: idToken.sub }, { secret, ttlSeconds: 3600, purpose: 'my-app:session' })// Set-Cookie: session=<value>; Max-Age=<maxAge>; HttpOnly; Secure; SameSite=Laxconst session = await verifySession(cookieValue, { secret, purpose: 'my-app:session' })

The payload must have a non-empty sub. signSession returns { value, maxAge }; maxAge equals the TTL, ready for a cookie. verifySession accepts only HS256, requires the sub, and returns null for a missing, edited, expired or wrong-purpose value.

Because the payload is readable, put nothing secret in it.

Errors#

Setup errors throw: a short secret, an empty rotation map, a TTL that is not a positive whole number, or an oversized payload. Everything that can be wrong with a value presented by a person returns null.

Worked example#

const cookie = request.headers.get('cookie') ?? ''const sealed = readCookie(cookie, 'checkout')const state = sealed === undefined ? null : await unseal<{ step: string }>(sealed, { secret, purpose: 'my-app:checkout' })if (state === null) return redirect('/start')

readCookie is yours.

Security notes#

  • Give every use its own purpose. A cookie sealed for one purpose cannot then be replayed into another.
  • Always set HttpOnly, Secure and SameSite. Sealing protects the content, not the cookie from script.
  • Rotate by adding a number, then removing the old one after the longest TTL has passed.
  • Do not seal tokens. They do not fit, and a copy of a sealed cookie is a copy of the credential. Keep them on your server.
  • Sealing does not stop replay. A stolen cookie works until it expires. Keep TTLs short.

Verified against commit 5d85077