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 tag
Payloads are compressed before encryption — to about a quarter of their size, since signal JSON is highly repetitive — because ciphertext would not compress at all.