Zeno dokumentaatio
Hanki API-avaimet

Palvelin

Sealed results

Trust the event the browser hands you without calling the API.

Sealed results are an option of identify: turn them on in the dashboard's Settings under Sealed results (how). Then identify also returns sealedResult: the event, encrypted with your project's encryption key. The browser can't read or change it, so your server can open it and trust it without a round trip to the API.

JSON
{
	"visitorId": "q1yLOPpzvOstEQCFQQxE",
	"requestId": "1792578564800.yU4Ety",
	"returning": true,
	"sealedResult": "AdC1z..."
}

Inside is the event as GET /events/{requestId} answers it, the same as a webhook body, but without velocity (worked out when you ask for it) and without updatedAt (nothing was attached yet). If your checks use velocity, call the API for those events, or for all of them.

Format

AES-256-GCM. sealedResult is standard base64 with padding (RFC 4648 section 4: +, / and =, not the URL-safe alphabet), and decodes to:

BytesOffsetContent
1[0]Version, 1
12[1:13]IV (the GCM nonce)
the rest[13:len-16]Ciphertext of the event's JSON, UTF-8
16[len-16:]Authentication tag
  • Key: the encryption key is standard base64 too; decode it to its 32 bytes and use those.
  • No additional authenticated data: pass none, or an empty one.
  • Tag: Node and PHP take it on its own; Go's cipher.AEAD.Open and Python's AESGCM.decrypt take ciphertext and tag together, which is [13:] as it is.
  • The openssl command line can't open it: openssl enc doesn't do GCM. Use a library.

Open it

import { createDecipheriv } from 'node:crypto'

const unseal = (sealed: string, key: string) => {
	const data = Buffer.from(sealed, 'base64')

	if (data[0] !== 1) {
		throw new Error('Unknown sealed result version')
	}

	const decipher = createDecipheriv('aes-256-gcm', Buffer.from(key, 'base64'), data.subarray(1, 13))
	decipher.setAuthTag(data.subarray(-16))

	return JSON.parse(Buffer.concat([decipher.update(data.subarray(13, -16)), decipher.final()]).toString())
}

Decryption fails when anything was changed or another key sealed it: Node, Python and Go throw or return an error, PHP's openssl_decrypt returns false. Then check the event like one from the API: its externalId is the user who just logged in and its createdAt is a few minutes old at most.

Test vector

KeyAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= (the bytes 0 to 31)
Sealed resultAT5KWTil7D9AYje+/X8RxOsu8/ktUVpY/Lt5YNw7qC0X3sGhnB1eR3uu17TfEfscSjS64+sxeXKVHroNhT93rtUvyvchtjym86C3sCbmo3HenA7mYO9oCp5Ts5+EJXexU4aPf7WFe5ICJDzwINEUg82QosMuWwBnNjGst7swEzibzmyfEa10vKmZYQD4g6z//JKIuTu5B/5uSANt+P3JHAUG58Q=
Opens to{"requestId":"1792578564800.yU4Ety","visitorId":"q1yLOPpzvOstEQCFQQxE","externalId":"user-2077","createdAt":"2026-10-21T12:58:49.476Z"}

Change any one character of the sealed result and it must fail to open. A real one holds the whole event; this one is short to fit.

Turn it on

  1. On the dashboard's Settings page, under Sealed results, press Create key. The key (32 bytes, base64) appears right there, once: copy it.
  2. Store it with your server's secrets and deploy.
  3. Turn sealed results on.

Replacing the key

A new key replaces the old one right away, and you only get it once it's made, so for the minutes until your server has it, sealed results won't open there. Two things keep logins working through the switch:

  • Fall back to the API. The browser also has the requestId: when a sealed result doesn't open, read the event with GET /events/{requestId}. That covers the switch and any other failure.
  • Keep the old key a while. Results sealed just before the switch only open with the old key, so try both for a few minutes after you deploy the new one:

With the Node SDK (in another language, the same loop with your own unseal and a GET /events/{requestId}):

Node SDK
import { createClient, unsealResult } from '@zeno-id/server'

const fingerprint = createClient({ secretKey: process.env.FP_SECRET_KEY, endpoint: 'https://api.zeno.id' })
const keys = [process.env.FP_ENCRYPTION_KEY, process.env.FP_ENCRYPTION_KEY_PREVIOUS].filter(key => key !== undefined)

const open = async (sealed: string, requestId: string) => {
	for (const key of keys) {
		try {
			return unsealResult(sealed, key)
		} catch {}
	}

	// Sealed with a key this server doesn't have yet, or changed: ask the API.
	return fingerprint.getEvent(requestId)
}