Verify the Signed Token

Every verdict your backend fetches carries a token: a copy of the verdict signed by Octet. Verify it whenever a verdict passes through a system you don't fully control, such as a queue or another service. Store it with your decision record, so you can later show exactly what Octet returned.

The token reaches only your backend. The browser never receives it.

This page covers the check at fetch time. To keep tokens for an auditor and check them after they expire, see Keep Verdicts for Audit.

Format

The token is a compact JWS (RFC 7515). Octet changes its signing key on 19 October 2026 at 15:00 UK time:

Tokens signed alg kid Key
Before 19 October 2026, 15:00 UK time EdDSA (Ed25519, RFC 8037) octet-browser-verdict-2026-09 An OKP Ed25519 key
From 19 October 2026, 15:00 UK time ES256 (ECDSA P-256, RFC 7518) octet-browser-verdict-2026-10 An EC P-256 key, held in a hardware security module

Accept both. Take the algorithm from the key the kid names, never from the token alone: ES256 for an EC key, EdDSA for an OKP key. The examples on this page do this, so they verify tokens from before and after the change.

The header of a token signed from 19 October 2026 is:

{ "alg": "ES256", "typ": "octet-browser-verdict+jwt;v=3", "kid": "octet-browser-verdict-2026-10" }

An ES256 signature is the raw 64-byte r and s, as RFC 7518 sets for JWS, not a DER-encoded signature.

The v in typ is the claim-set version. Version 2 added deviceKey, and version 3 added chain, chain_root and clientNetMatch and removed clientNet. Version 4 adds stage, revision and incomplete, and is issued only to a license with the early verdict. Accept ;v=1, ;v=2, ;v=3 and ;v=4, so that tokens you stored before an update still verify. Check typ against those four values exactly. That check stops another kind of JWT from being accepted in its place.

Claims

Claim Type Meaning
iss string Always "octet-browser".
iat number When the token was signed, in Unix seconds.
exp number When the token expires, in Unix seconds. Always iat plus 300, so 5 minutes.
sessionRef string The sessionRef the verdict belongs to.
country string As in the verdict. Absent when the verdict has no country.
confidence number As in the verdict.
alarm string As in the verdict.
deviceKey string As in the verdict. See Verdict Reference. From version 2.
clientNetMatch boolean Whether the collection came from the network your backend saw. Present only when your backend sent clientIp with the challenge request. See Check the user's network. From version 3.
chain object A record of the signed steps of the session. Opaque, except chain.s0. See The chain. From version 3.
chain_root string Opaque. Store it with the token. See The chain. From version 3.
stage string provisional or final. In every version 4 token. Version 4 is issued only to a license with the early verdict.
revision integer 1 or more, in every version 4 token. It counts up within one sessionRef, and a higher revision replaces a lower one.
incomplete boolean true when the second report never arrived. Only with stage final, absent otherwise. From version 4.
modelVersion string An opaque version string for the Octet release that produced the verdict.

Absent values are left out, never sent as null. The claims repeat the verdict fields beside the token in the same response. After verifying, use the claims and not the unsigned fields. Ignore claims you don't recognise.

The key set

Octet publishes its verification keys as a JSON Web Key Set:

https://geo.octetproof.com/.well-known/browser-verdict-jwks.json

From 19 October 2026, 15:00 UK time, it lists the ES256 key:

{
  "keys": [
    { "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", "kid": "octet-browser-verdict-2026-10", "x": "...", "y": "...", "octet_purpose": "browser-verdict" }
  ]
}

Before then, it lists the Ed25519 key, which has "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "kid": "octet-browser-verdict-2026-09", and a single x.

Pick the key whose kid matches the token header. Its key type sets the algorithm: EdDSA for an OKP Ed25519 key and ES256 for an EC P-256 key. Refuse a token whose alg doesn't match its key. Cache the key set for up to 5 minutes, and fetch it again when a token carries a kid you haven't seen. That is how you pick up a new key without a restart.

When the key changes

The key set lists the key Octet signs with now. For 5 minutes after a key change, the token lifetime, it also lists the key before it, so a token signed just before the change still verifies until it expires. After that, the old key leaves the key set.

The old key stays in the key archive, at https://geo.octetproof.com/.well-known/browser-verdict-jwks-archive.json, which lists every key Octet has signed verdicts with and never drops one. After the change on 19 October 2026, the archive lists both octet-browser-verdict-2026-09 and octet-browser-verdict-2026-10, so tokens you stored before the change stay verifiable. See Keep Verdicts for Audit.

Every key in the archive is also listed in a manifest signed by Octet's offline root keys. See Check a key against Octet's root keys.

What to check

  1. The header's typ is octet-browser-verdict+jwt;v=4, ;v=3, ;v=2 or ;v=1.
  2. The key for the header's kid is an OKP Ed25519 key and alg is EdDSA, or it is an EC P-256 key and alg is ES256.
  3. The signature verifies with that key.
  4. iss is octet-browser.
  5. exp has not passed. Allow up to 30 seconds of clock difference.
  6. sessionRef equals the sessionRef you created for this user's session. This stops a valid token from another session being replayed as this one.
  7. In a version 4 token, stage is final before a gated action. See Early Verdict.

Recommended: the first time you meet a kid, also check that Octet's root-signed manifest lists its key. See Check a key against Octet's root keys.

These checks are for a token you have just fetched. A stored token is already past its exp, so it gets a different time check. See Check a stored token.

Node.js

Node.js 18 or later, with no dependencies. Save this as verify-verdict.mjs:

// verify-verdict.mjs: verify an Octet verdict token. Node.js 18 or later, no dependencies.
import { createPublicKey, verify } from 'node:crypto';

const JWKS_URL =
  process.env.OCTET_JWKS_URL ?? 'https://geo.octetproof.com/.well-known/browser-verdict-jwks.json';
const TYPS = [
  'octet-browser-verdict+jwt;v=4',
  'octet-browser-verdict+jwt;v=3',
  'octet-browser-verdict+jwt;v=2',
  'octet-browser-verdict+jwt;v=1',
];
const LEEWAY_S = 30; // allowed clock difference, in seconds

let cache = { fetchedAt: 0, keys: [] };

async function getJwk(kid) {
  const stale = Date.now() - cache.fetchedAt > 300_000;
  let jwk = stale ? undefined : cache.keys.find((k) => k.kid === kid);
  if (!jwk) {
    // Refetch when the cache is older than 5 minutes or the kid is new.
    const res = await fetch(JWKS_URL, { signal: AbortSignal.timeout(5000) });
    if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
    cache = { fetchedAt: Date.now(), keys: (await res.json()).keys };
    jwk = cache.keys.find((k) => k.kid === kid);
  }
  if (!jwk) throw new Error(`unknown key id: ${kid}`);
  return jwk;
}

const decode = (part) => JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));

/**
 * Checks the signature of a token with a JWK from Octet's key set. The key type
 * sets the algorithm: EdDSA for an OKP Ed25519 key, ES256 for an EC P-256 key.
 */
export function checkSignature(token, jwk) {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('malformed token');
  const [h, p, s] = parts;
  const header = decode(h);
  if (!TYPS.includes(header.typ)) throw new Error('not an Octet verdict token');
  if (header.kid !== jwk.kid) throw new Error('key id does not match the token');
  const input = Buffer.from(`${h}.${p}`);
  const sig = Buffer.from(s, 'base64url');
  let ok;
  if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519' && header.alg === 'EdDSA') {
    const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' });
    ok = verify(null, input, key, sig);
  } else if (jwk.kty === 'EC' && jwk.crv === 'P-256' && header.alg === 'ES256') {
    const key = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y }, format: 'jwk' });
    ok = verify('sha256', input, { key, dsaEncoding: 'ieee-p1363' }, sig);
  } else {
    throw new Error('alg does not match the key');
  }
  if (!ok) throw new Error('bad signature');
  return decode(p);
}

/** Returns the verified claims, or throws. */
export async function verifyVerdictToken(token, expectedSessionRef) {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('malformed token');
  const jwk = await getJwk(decode(parts[0]).kid);
  const claims = checkSignature(token, jwk);

  const now = Math.floor(Date.now() / 1000);
  if (claims.iss !== 'octet-browser') throw new Error('wrong issuer');
  if (typeof claims.exp !== 'number' || now > claims.exp + LEEWAY_S) throw new Error('expired');
  if (claims.sessionRef !== expectedSessionRef) throw new Error('sessionRef does not match');
  return claims;
}

Use it with the sessionRef you stored for this user:

import { verifyVerdictToken } from './verify-verdict.mjs';

const claims = await verifyVerdictToken(verdict.token, sessionRef);
// claims.country, claims.confidence, claims.alarm

Python

Python 3.9 or later, with PyJWT 2.6 or later. Install it with pip install "pyjwt[crypto]>=2.6" and save this as verify_verdict.py:

# verify_verdict.py: verify an Octet verdict token. Python 3.9 or later.
# pip install "pyjwt[crypto]>=2.6"
import os

import jwt

JWKS_URL = os.environ.get(
    "OCTET_JWKS_URL", "https://geo.octetproof.com/.well-known/browser-verdict-jwks.json"
)
TYPS = (
    "octet-browser-verdict+jwt;v=4",
    "octet-browser-verdict+jwt;v=3",
    "octet-browser-verdict+jwt;v=2",
    "octet-browser-verdict+jwt;v=1",
)
LEEWAY_S = 30  # allowed clock difference, in seconds

# Caches the keys for 5 minutes and refetches when it meets a new key id.
_jwks = jwt.PyJWKClient(JWKS_URL, cache_keys=True, lifespan=300)


def check_header(token: str, key: "jwt.PyJWK") -> None:
    """Checks typ, and that alg is the one the key's type sets: EdDSA for OKP Ed25519, ES256 for EC P-256."""
    header = jwt.get_unverified_header(token)
    if header.get("typ") not in TYPS:
        raise ValueError("not an Octet verdict token")
    if header.get("kid") != key.key_id:
        raise ValueError("key id does not match the token")
    if key.algorithm_name not in ("EdDSA", "ES256") or header.get("alg") != key.algorithm_name:
        raise ValueError("alg does not match the key")


def verify_verdict_token(token: str, expected_session_ref: str) -> dict:
    """Returns the verified claims, or raises."""
    key = _jwks.get_signing_key(jwt.get_unverified_header(token)["kid"])
    check_header(token, key)
    claims = jwt.decode(
        token,
        key.key,
        algorithms=[key.algorithm_name],
        issuer="octet-browser",
        leeway=LEEWAY_S,
        options={"require": ["iss", "iat", "exp", "sessionRef"]},
    )
    if claims["sessionRef"] != expected_session_ref:
        raise ValueError("sessionRef does not match")
    return claims

Use it with the sessionRef you stored for this user:

from verify_verdict import verify_verdict_token

claims = verify_verdict_token(verdict["token"], session_ref)
# claims["country"], claims["confidence"], claims["alarm"]

Check the user's network

clientNetMatch tells you whether the collection reached your edge from the same network your backend saw. To get it, send the user's IP address as clientIp when your backend requests the challenge. See Get a challenge.

Octet compares the network of that address with the network your edge saw. The token carries only the result:

Value Meaning
true The collection came from the same network as clientIp.
false The collection came from a different network.
Absent Your backend sent no clientIp.

Don't block on false alone. A user can reach your edge and your backend over different networks, for example over IPv6 to one and IPv4 to the other. Record the mismatch and weigh it with the verdict.

The chain

From version 3, the token carries chain and chain_root. They record the signed steps Octet checked for the session. They contain no collected data and no IP address. Treat both as opaque, and store them with the token for audit. The token's signature already covers them.

You can check one link yourself. Keep the challenge token your backend received from POST /v1/challenge. chain.s0 equals the SHA-256 of that token's exact string, base64url-encoded without padding:

import { createHash } from 'node:crypto';

const s0 = createHash('sha256').update(challengeToken).digest('base64url');
if (claims.chain?.s0 !== s0) throw new Error('verdict does not answer this challenge');

The challenge's public key is at https://geo.octetproof.com/.well-known/browser-challenge-jwks.json.

The chain shows which signed inputs the verdict was computed from. It doesn't show that what the browser reported is true. See Integrity and Audit.

Check a key against Octet's root keys

The key set, the key archive and the challenge key set are all served by geo.octetproof.com. To check a key without relying on that server, check it against the manifest that Octet's offline root keys sign.

The manifest is a compact JWS at:

https://geo.octetproof.com/.well-known/octet-browser-manifest.json.jws

Its header is { "alg": "ES256", "typ": "octet-root+jws;v=1", "kid": "<root key id>" }, and its payload is the manifest JSON. https://geo.octetproof.com/.well-known/octet-browser-manifest.json serves the payload alone.

Pin these two root public keys in your code. Copy them from this page, and never fetch a root key from the server whose keys you are checking:

{
  "roots": [
    {
      "kty": "EC",
      "crv": "P-256",
      "kid": "octet-root-a-2026-10",
      "x": "-TTm69g0eOIcWb-I9gVwsZCsSNTSPJIjmuJQetUVO7o",
      "y": "SfDnpa7Jtnw8FDYvcPnZUJZN8D2wQSztUMlucUHyM6A",
      "octet_purpose": "octet-root"
    },
    {
      "kty": "EC",
      "crv": "P-256",
      "kid": "octet-root-b-2026-10",
      "x": "Nl3Zsqh92UZPPCVGqpQYPP-pZoKI7_YNhGG4qaS4qM8",
      "y": "6MdPuYVrKIeDV4_gSDfqL5EnABIwXSyjo8-gd3i9TtM",
      "octet_purpose": "octet-root"
    }
  ]
}

The private halves of the root keys are kept offline, never on a server. One signature from either root key is enough.

What the manifest lists

Field Meaning
typ, v Always "octet-browser-manifest" and 1.
seq The manifest's number. Each new manifest has a higher seq.
iat, exp When the manifest was signed, and when it expires, in Unix seconds.
verdictKeys Every key Octet has signed verdict tokens with, exactly as the key archive lists them, with octet_not_before, octet_not_after and any revocation.
challengeKeys The keys that sign challenges.
roots The kids of the root keys that may sign the next manifest.

From v1.6.0 the manifest also lists Octet's license keys and other keys Octet's own services check. Its typ and v are unchanged. Ignore the fields you don't use, so a manifest that adds fields doesn't break your check.

Check the manifest

To trust a verdict key:

  1. Verify the manifest JWS with the pinned root key named by its kid. The algorithm is ES256, and the signature is the raw 64-byte r and s.
  2. Check that the payload has "typ": "octet-browser-manifest" and "v": 1, and that its exp has not passed.
  3. Remember the highest seq you have seen, and refuse a manifest with a lower one. This stops an older manifest, signed before a key was revoked, from being served to you again.
  4. Accept the token's kid only if the manifest's verdictKeys lists it with the same key. A challenge key is checked the same way, against challengeKeys.

Octet signs a new manifest, with seq one higher, whenever a key changes, and at least once a year. Octet's servers sign with a verdict key only while the manifest they serve lists it, so the manifest lists octet-browser-verdict-2026-10 from the moment that key signs its first token, on 19 October 2026 at 15:00 UK time. Because roots names the root keys that may sign the next manifest, Octet can replace a root key without you pinning again.

Check the manifest when your verifier meets a kid it hasn't checked before, not on every token. Keep the keys it confirmed, and the highest seq.

In Node.js 18 or later, with no dependencies. Save this as check-manifest.mjs:

// check-manifest.mjs: check Octet's keys against its root-signed manifest. Node.js 18 or later, no dependencies.
import { createPublicKey, verify } from 'node:crypto';

const MANIFEST_URL =
  process.env.OCTET_MANIFEST_URL ??
  'https://geo.octetproof.com/.well-known/octet-browser-manifest.json.jws';

// Octet's root public keys, pinned from the docs. Never fetch these from Octet's servers.
const ROOTS = [
  {
    kty: 'EC', crv: 'P-256', kid: 'octet-root-a-2026-10',
    x: '-TTm69g0eOIcWb-I9gVwsZCsSNTSPJIjmuJQetUVO7o',
    y: 'SfDnpa7Jtnw8FDYvcPnZUJZN8D2wQSztUMlucUHyM6A',
  },
  {
    kty: 'EC', crv: 'P-256', kid: 'octet-root-b-2026-10',
    x: 'Nl3Zsqh92UZPPCVGqpQYPP-pZoKI7_YNhGG4qaS4qM8',
    y: '6MdPuYVrKIeDV4_gSDfqL5EnABIwXSyjo8-gd3i9TtM',
  },
];

const decode = (part) => JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));

/**
 * Fetches the manifest, verifies it with a pinned root key, and returns its payload.
 * Pass the highest seq you have stored, and store the returned seq.
 */
export async function fetchManifest(highestSeqSeen = 0) {
  const res = await fetch(MANIFEST_URL, { signal: AbortSignal.timeout(5000) });
  if (!res.ok) throw new Error(`manifest fetch failed: ${res.status}`);
  const parts = (await res.text()).trim().split('.');
  if (parts.length !== 3) throw new Error('malformed manifest');
  const [h, p, s] = parts;
  const header = decode(h);
  if (header.alg !== 'ES256' || header.typ !== 'octet-root+jws;v=1') {
    throw new Error('not an Octet root manifest');
  }
  const root = ROOTS.find((r) => r.kid === header.kid);
  if (!root) throw new Error(`manifest signed by an unpinned key: ${header.kid}`);
  const key = createPublicKey({ key: root, format: 'jwk' });
  const ok = verify('sha256', Buffer.from(`${h}.${p}`), { key, dsaEncoding: 'ieee-p1363' },
    Buffer.from(s, 'base64url'));
  if (!ok) throw new Error('bad manifest signature');

  const manifest = decode(p);
  if (manifest.typ !== 'octet-browser-manifest' || manifest.v !== 1) {
    throw new Error('unknown manifest format');
  }
  if (typeof manifest.exp !== 'number' || Date.now() / 1000 > manifest.exp) {
    throw new Error('manifest expired');
  }
  if (typeof manifest.seq !== 'number' || manifest.seq < highestSeqSeen) {
    throw new Error('manifest is older than one already seen');
  }
  return manifest;
}

const sameKey = (a, b) =>
  a.kid === b.kid && a.kty === b.kty && a.crv === b.crv && a.x === b.x && (a.y ?? null) === (b.y ?? null);

/** Returns the manifest's entry for a verdict key, or throws if the manifest doesn't list that key. */
export function listedVerdictKey(manifest, jwk) {
  const entry = manifest.verdictKeys.find((k) => sameKey(k, jwk));
  if (!entry) throw new Error(`verdict key not in Octet's manifest: ${jwk.kid}`);
  return entry;
}

In Python 3.9 or later, with PyJWT 2.6 or later. Save this as check_manifest.py:

# check_manifest.py: check Octet's keys against its root-signed manifest. Python 3.9 or later.
# pip install "pyjwt[crypto]>=2.6"
import json
import os
import time
import urllib.request

import jwt

MANIFEST_URL = os.environ.get(
    "OCTET_MANIFEST_URL", "https://geo.octetproof.com/.well-known/octet-browser-manifest.json.jws"
)

# Octet's root public keys, pinned from the docs. Never fetch these from Octet's servers.
ROOTS = {
    "octet-root-a-2026-10": {
        "kty": "EC", "crv": "P-256", "kid": "octet-root-a-2026-10",
        "x": "-TTm69g0eOIcWb-I9gVwsZCsSNTSPJIjmuJQetUVO7o",
        "y": "SfDnpa7Jtnw8FDYvcPnZUJZN8D2wQSztUMlucUHyM6A",
    },
    "octet-root-b-2026-10": {
        "kty": "EC", "crv": "P-256", "kid": "octet-root-b-2026-10",
        "x": "Nl3Zsqh92UZPPCVGqpQYPP-pZoKI7_YNhGG4qaS4qM8",
        "y": "6MdPuYVrKIeDV4_gSDfqL5EnABIwXSyjo8-gd3i9TtM",
    },
}


def fetch_manifest(highest_seq_seen: int = 0) -> dict:
    """Fetches the manifest, verifies it with a pinned root key, and returns its payload.
    Pass the highest seq you have stored, and store the returned seq."""
    with urllib.request.urlopen(MANIFEST_URL, timeout=5) as res:
        jws = res.read().decode().strip()
    header = jwt.get_unverified_header(jws)
    if header.get("alg") != "ES256" or header.get("typ") != "octet-root+jws;v=1":
        raise ValueError("not an Octet root manifest")
    root = ROOTS.get(header.get("kid"))
    if root is None:
        raise ValueError(f"manifest signed by an unpinned key: {header.get('kid')}")
    payload = jwt.api_jws.decode(jws, jwt.PyJWK(root, "ES256").key, algorithms=["ES256"])
    manifest = json.loads(payload)
    if manifest.get("typ") != "octet-browser-manifest" or manifest.get("v") != 1:
        raise ValueError("unknown manifest format")
    if not isinstance(manifest.get("exp"), int) or time.time() > manifest["exp"]:
        raise ValueError("manifest expired")
    if not isinstance(manifest.get("seq"), int) or manifest["seq"] < highest_seq_seen:
        raise ValueError("manifest is older than one already seen")
    return manifest


def listed_verdict_key(manifest: dict, jwk: dict) -> dict:
    """Returns the manifest's entry for a verdict key, or raises if the manifest doesn't list that key."""
    fields = ("kid", "kty", "crv", "x", "y")
    for entry in manifest["verdictKeys"]:
        if all(entry.get(f) == jwk.get(f) for f in fields):
            return entry
    raise ValueError(f"verdict key not in Octet's manifest: {jwk.get('kid')}")

Use it with the key your verifier picked for the token's kid, before you trust that key for the first time:

import { fetchManifest, listedVerdictKey } from './check-manifest.mjs';

const manifest = await fetchManifest(highestSeqSeen);
listedVerdictKey(manifest, jwk); // throws if Octet's root keys didn't sign this key
highestSeqSeen = manifest.seq;   // store it
from check_manifest import fetch_manifest, listed_verdict_key

manifest = fetch_manifest(highest_seq_seen)
listed_verdict_key(manifest, jwk)  # raises if Octet's root keys didn't sign this key
highest_seq_seen = manifest["seq"]  # store it

For a stored token, use the entry listedVerdictKey returns: its octet_not_before, octet_not_after and any octet_revoked_at are the dates for the check in Confirm each key.

Test the examples

You can test your verifier before you have live traffic, with a token signed by a local test key.

  1. Put verify-verdict.mjs or verify_verdict.py in an empty directory, and save this script next to it as make-test-token.mjs:
// make-test-token.mjs: sign a test verdict token with a local test key. Node.js 18 or later.
// Usage: node make-test-token.mjs <sessionRef> [ageSeconds]
// Writes test-key.pem (reused on later runs) and .well-known/browser-verdict-jwks.json.
import { createPrivateKey, createPublicKey, generateKeyPairSync, sign } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';

const [sessionRef = 'test-session-ref', age = '0'] = process.argv.slice(2);
if (!existsSync('test-key.pem')) {
  const { privateKey } = generateKeyPairSync('ed25519');
  writeFileSync('test-key.pem', privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
}
const privateKey = createPrivateKey(readFileSync('test-key.pem'));
const kid = 'test-key';
const { x } = createPublicKey(privateKey).export({ format: 'jwk' });
mkdirSync('.well-known', { recursive: true });
writeFileSync('.well-known/browser-verdict-jwks.json',
  JSON.stringify({ keys: [{ kty: 'OKP', crv: 'Ed25519', use: 'sig', alg: 'EdDSA', kid, x }] }));

const b64 = (v) => Buffer.from(JSON.stringify(v)).toString('base64url');
const iat = Math.floor(Date.now() / 1000) - Number(age);
const header = b64({ alg: 'EdDSA', typ: 'octet-browser-verdict+jwt;v=3', kid });
const claims = b64({
  iss: 'octet-browser', iat, exp: iat + 300, sessionRef,
  country: 'DE', confidence: 0.9, alarm: 'none', modelVersion: 'test',
});
const signature = sign(null, Buffer.from(`${header}.${claims}`), privateKey).toString('base64url');
console.log(`${header}.${claims}.${signature}`);
  1. Create three tokens: a valid one, one signed 10 minutes ago, and one for a different sessionRef:
node make-test-token.mjs ref-1 > good.txt
node make-test-token.mjs ref-1 600 > expired.txt
node make-test-token.mjs ref-2 > other.txt
  1. Serve the test key set and point the verifier at it:
python3 -m http.server 8766 --bind 127.0.0.1 &
export OCTET_JWKS_URL=http://127.0.0.1:8766/.well-known/browser-verdict-jwks.json
  1. Save one of these checks next to the verifier and run it. Each also builds a fourth token by editing alarm in the valid token's payload.
// check.mjs: run with node check.mjs
import { readFileSync } from 'node:fs';
import { verifyVerdictToken } from './verify-verdict.mjs';

const read = (f) => readFileSync(f, 'utf8').trim();
const [h, p, s] = read('good.txt').split('.');
const edited = JSON.parse(Buffer.from(p, 'base64url').toString());
edited.alarm = 'high';
const tampered = `${h}.${Buffer.from(JSON.stringify(edited)).toString('base64url')}.${s}`;

const cases = { good: read('good.txt'), tampered, expired: read('expired.txt'), other: read('other.txt') };
for (const [name, token] of Object.entries(cases)) {
  try {
    await verifyVerdictToken(token, 'ref-1');
    console.log(name, 'accepted');
  } catch (e) {
    console.log(name, 'rejected:', e.message);
  }
}
# check.py: run with python3 check.py
import base64
import json

from verify_verdict import verify_verdict_token


def read(f):
    return open(f).read().strip()


h, p, s = read("good.txt").split(".")
edited = json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)))
edited["alarm"] = "high"
tampered = ".".join([h, base64.urlsafe_b64encode(json.dumps(edited).encode()).decode().rstrip("="), s])

cases = {"good": read("good.txt"), "tampered": tampered, "expired": read("expired.txt"), "other": read("other.txt")}
for name, token in cases.items():
    try:
        verify_verdict_token(token, "ref-1")
        print(name, "accepted")
    except Exception as e:
        print(name, "rejected:", e)

The output shows good accepted, and tampered, expired and other each rejected.

Unset OCTET_JWKS_URL before you deploy, so the verifier uses Octet's key set.

TermsPrivacy© 2026 Understone, Inc. d/b/a Octet