Search the docs

Find a page or a section of one

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

js
const { sealedResult } = await agent.get({ extendedResult: true })

await fetch("/api/checkout", {
  method: "POST",
  body: JSON.stringify({ sealedResult, cart }),
})
js
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.

js
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.

js
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.

  1. Generate the new key. It is listed as Waiting (activatedAt: null).
  2. Deploy it to your backend beside the old one, newest first.
  3. Activate it (POST /encryption-keys/{id}/activate): every server seals with it within five seconds.
  4. 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.

js
try {
  event = unsealEventResponse(sealed, keys)
} catch {
  return deny()   // not "retry", not "log and continue"
}

#When to use which

Sealed resultLowest latency. The client already has the payload; you just open it.
Server API fetchSimplest. 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:

text
[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.