Sealed results
A way to skip the server-to-server round trip without trusting the client.
With an active encryption key, every identification response also carries a
sealedResult: the full payload, encrypted. The browser cannot read or edit it,
but it can forward it to your backend, which decrypts it with a key only the two
servers hold.
API keys → Sealed results → Generate key, or
POST /api/management/v1/encryption-keys
from a pipeline. The key is shown once. Your first key seals at once: every
server seals with it within five seconds (each reads the key from a copy that
old), and stops within five of its being revoked.
#Use it
const { sealedResult } = await agent.get({ extendedResult: true })
await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify({ sealedResult, cart }),
})import { unsealEventResponse } from "@antibot/node-sdk"
const event = unsealEventResponse(sealedResult, [
{ key: process.env.ANTIBOT_SEAL_KEY },
])
if (event.products.bot.result === "bad") deny()Equivalents ship in every server SDK.
#Trust it fresh, and once
A sealed result cannot be edited, but it can be copied. One earned by a real
browser, kept and sent again, opens as well the hundredth time as the first
— from a bot farm, if that is who holds it — and it never expires: nothing
in the envelope says how old it is. What it carries does: its timestamp
(epoch milliseconds) and its requestId, one identification's own.
const event = unsealEventResponse(sealedResult, keys)
// Fresh: this page's identification, not one kept from another visit.
if (Date.now() - event.timestamp > 2 * 60_000) return deny()
// Once: one identification is one pass, not a pass for whoever copies it.
if (!(await redis.set(`sealed:${event.requestId}`, "1", { NX: true, EX: 600 }))) return deny()For an action — a sign-in, a checkout — identify as it happens rather than
send what the page identified on load: the agent keeps a result for half an
hour (cacheTtlMs), so agent.get({ extendedResult: true, ignoreCache: true })
(a linkedId or a tag asks afresh too) is what gives your backend a payload
seconds old.
An organization's environments share its encryption keys, and a payload does
not name its environment: one from staging opens on production's backend. Where
that matters, read the event through the Server API instead
(GET /events/{requestId} with the environment's secret key answers only for
its own events), or keep the environments in separate organizations.
#Rotation
Several keys can be active at once, so rotating breaks nothing already in flight. Pass them newest first; each is tried in order.
unsealEventResponse(sealed, [{ key: current }, { key: previous }])A key made while another seals waits: the one sealing now goes on sealing, so no identification is sealed with a key your backend does not hold yet.
- Generate the new key. It is listed as Waiting (
activatedAt: null). - Deploy it to your backend beside the old one, newest first.
- Activate it (
POST /encryption-keys/{id}/activate): every server seals with it within five seconds. - Once what the old key sealed is no longer in flight — a payload lives as long as the page that carries it — revoke the old one.
Activating a key that sealed before puts it back to sealing: the way back if a
backend turns out not to hold the new one. A pipeline that deploys the key
first and only then creates it can pass "activate": true to seal at once.
Revoking stops a key sealing; it cannot stop a backend that holds it from
opening what it sealed.
#A wrong key and a tampered payload are the same error
Both throw SealedResultError and are not distinguished. Both mean the same
thing — do not trust this result — and saying which would tell an attacker
whether they had guessed the key.
try {
event = unsealEventResponse(sealed, keys)
} catch {
return deny() // not "retry", not "log and continue"
}#When to use which
| Sealed result | Lowest latency. The client already has the payload; you just open it. |
| Server API fetch | Simplest. One GET with the request id, nothing to rotate. |
Both are equally trustworthy: sealed payloads are authenticated, so an edited one fails to open rather than opening to edited data. Choose on latency and operational taste, not on security.
#Wire format
The envelope matches Fingerprint's byte for byte, so existing unsealing code works unchanged:
[0..4) magic 0x9E85DCED, big-endian
[4..16) 96-bit GCM nonce
[16..-16) raw-deflate of the JSON payload, AES-256-GCM encrypted
[-16..] 128-bit GCM authentication tagPayloads are compressed before encryption — to about a quarter of their size, since signal JSON is highly repetitive — because ciphertext would not compress at all.