Keep Verdicts for Audit
This guide sets up the records an auditor needs to check your verdicts later, and shows how to check a stored token after it has expired. It builds on verify-verdict.mjs or verify_verdict.py from Verify the Signed Token. Why the check works is in Integrity and Audit.
Octet holds each verdict and its token for 2 minutes and keeps neither after that, so your records are the only copy you can verify. The device record that Understone Ltd keeps for your license holds each session's country, confidence and alarm, but no token, so it can't stand in for your records.
1. Keep every Octet key you verify with
Octet's key set lists the key Octet signs with today. After Octet moves to a new key, the old key leaves the key set within 5 minutes. Octet keeps every key it has signed verdicts with in its key archive, at https://geo.octetproof.com/.well-known/browser-verdict-jwks-archive.json. Keep your own copy as well, so that a check of your records doesn't depend on reaching Octet: the first time a token carries a kid you haven't stored, store that key's entry from the key set.
Tokens signed before 19 October 2026, 15:00 UK time, carry the kid octet-browser-verdict-2026-09, an Ed25519 key. Tokens signed from then carry octet-browser-verdict-2026-10, an ECDSA P-256 key. Keep both. The archive keeps octet-browser-verdict-2026-09 after it leaves the key set, so if you didn't store it, take it from the archive. See When the key changes.
In Node.js, with octetKeys standing for your own storage:
const JWKS_URL =
process.env.OCTET_JWKS_URL ?? 'https://geo.octetproof.com/.well-known/browser-verdict-jwks.json';
/** Stores the key set entry for a token's kid, the first time you see that kid. */
export async function keepKey(token) {
const { kid } = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8'));
if (await octetKeys.has(kid)) return;
const res = await fetch(JWKS_URL, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
const jwk = (await res.json()).keys.find((k) => k.kid === kid);
if (!jwk) throw new Error(`unknown key id: ${kid}`);
await octetKeys.insert({ kid, jwk, firstSeen: new Date().toISOString() });
}
In Python:
import json
import os
import urllib.request
from datetime import datetime, timezone
import jwt
JWKS_URL = os.environ.get(
"OCTET_JWKS_URL", "https://geo.octetproof.com/.well-known/browser-verdict-jwks.json"
)
def keep_key(token: str) -> None:
"""Stores the key set entry for a token's kid, the first time you see that kid."""
kid = jwt.get_unverified_header(token)["kid"]
if octet_keys.has(kid):
return
with urllib.request.urlopen(JWKS_URL, timeout=5) as res:
keys = json.load(res)["keys"]
jwk = next((k for k in keys if k["kid"] == kid), None)
if jwk is None:
raise ValueError(f"unknown key id: {kid}")
octet_keys.insert(kid=kid, jwk=jwk, first_seen=datetime.now(timezone.utc).isoformat())
Call it only after the token has passed verifyVerdictToken, so that you store only keys that verified a real token.
2. Store a record for each decision
When your backend acts on a verdict, verify the token, keep its key, and store these fields together:
| Field | Where it comes from | Why |
|---|---|---|
token |
The verdict response | The signed verdict. Every claim is inside it. |
sessionRef |
The user's server-side session | Links the token to the session. |
| The user and the action | Your application | Links the session to a person and a decision, which the token doesn't record. |
decision |
Your policy | What you did, for example allow, require KYC, or block. |
policyVersion |
Your deployment | Which version of your policy made the decision. |
decidedAt |
Your server's clock | When you acted. It falls between the token's iat and exp. |
Store token exactly as Octet returned it. Any change to the string, including re-encoding it, makes the signature fail.
In Node.js:
import { verifyVerdictToken } from './verify-verdict.mjs';
const claims = await verifyVerdictToken(verdict.token, sessionRef);
await keepKey(verdict.token);
await auditLog.insert({
token: verdict.token,
sessionRef,
userId: user.id,
action: 'withdrawal',
decision: decide(claims),
policyVersion: POLICY_VERSION,
decidedAt: new Date().toISOString(),
});
In Python:
from datetime import datetime, timezone
from verify_verdict import verify_verdict_token
claims = verify_verdict_token(verdict["token"], session_ref)
keep_key(verdict["token"])
audit_log.insert(
token=verdict["token"],
session_ref=session_ref,
user_id=user.id,
action="withdrawal",
decision=decide(claims),
policy_version=POLICY_VERSION,
decided_at=datetime.now(timezone.utc).isoformat(),
)
auditLog, user and POLICY_VERSION stand for your own storage and request data, and decide for your policy, such as the one in Verdicts. Keep the records for as long as your obligations require.
3. Check a stored token
At fetch time, exp stops an old token from being accepted. A stored token is already past its exp, so this check compares the token's lifetime with your record instead: the decision time must fall between iat and exp. The other checks are the same as at fetch time, run with the key you kept.
Node.js 18 or later, with no dependencies. It uses checkSignature from verify-verdict.mjs. Save this as verify-stored.mjs in the same directory:
// verify-stored.mjs: check a stored Octet verdict token. Node.js 18 or later, no dependencies.
import { checkSignature } from './verify-verdict.mjs';
const LEEWAY_S = 30; // allowed clock difference between your servers and Octet's, in seconds
/** The kid in a token's header. */
export const kidOf = (token) =>
JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8')).kid;
/**
* Checks a token from your records with the key you kept for it.
* Returns the claims, or throws.
*/
export function verifyStoredToken(token, { jwk, sessionRef, decidedAt }) {
// Checks typ, the key id, that alg matches the key type, and the signature.
const claims = checkSignature(token, jwk);
if (claims.iss !== 'octet-browser') throw new Error('wrong issuer');
if (claims.sessionRef !== sessionRef) throw new Error('sessionRef does not match the record');
const t = Math.floor(new Date(decidedAt).getTime() / 1000);
if (Number.isNaN(t)) throw new Error('decidedAt is not a date');
if (t < claims.iat - LEEWAY_S || t > claims.exp + LEEWAY_S) {
throw new Error('decision time is outside the token lifetime');
}
return claims;
}
Python 3.9 or later, with PyJWT. It uses check_header from verify_verdict.py. Save this as verify_stored.py in the same directory:
# verify_stored.py: check a stored Octet verdict token. Python 3.9 or later.
# pip install "pyjwt[crypto]"
from datetime import datetime
import jwt
from verify_verdict import check_header
LEEWAY_S = 30 # allowed clock difference between your servers and Octet's, in seconds
def kid_of(token: str) -> str:
"""The kid in a token's header."""
return jwt.get_unverified_header(token)["kid"]
def verify_stored_token(token: str, jwk: dict, session_ref: str, decided_at: datetime) -> dict:
"""Checks a token from your records with the key you kept for it. Returns the claims, or raises."""
key = jwt.PyJWK(jwk)
check_header(token, key) # typ, the key id, and that alg matches the key type
claims = jwt.decode(
token,
key.key,
algorithms=[key.algorithm_name],
issuer="octet-browser",
options={"verify_exp": False, "require": ["iss", "iat", "exp", "sessionRef"]},
)
if claims["sessionRef"] != session_ref:
raise ValueError("sessionRef does not match the record")
t = decided_at.timestamp()
if t < claims["iat"] - LEEWAY_S or t > claims["exp"] + LEEWAY_S:
raise ValueError("decision time is outside the token lifetime")
return claims
Pass decided_at as a timezone-aware datetime, for example datetime.fromisoformat(record.decided_at) for a value stored with its UTC offset.
Run it for one record:
import { kidOf, verifyStoredToken } from './verify-stored.mjs';
const { jwk } = await octetKeys.get(kidOf(record.token));
const claims = verifyStoredToken(record.token, {
jwk,
sessionRef: record.sessionRef,
decidedAt: record.decidedAt,
});
// claims.country, claims.confidence, claims.alarm, claims.iat
If the claims have clientNetMatch, it records whether the collection came from the network your backend saw. false alone isn't a failure. See Check the user's network.
4. Test the check
This test uses the files from Test the examples on the Verify the Signed Token page.
- Put
verify-stored.mjsorverify_stored.pyin the same directory asmake-test-token.mjsand the verifier from that page, and create a token:
node make-test-token.mjs ref-1 > good.txt
- Save one of these checks next to it and run it. Each builds four failing cases from the valid token: an edited payload, a different key, a different
sessionRef, and a decision recorded an hour after the token was signed.
// check-stored.mjs: run with node check-stored.mjs
import { generateKeyPairSync } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { verifyStoredToken } from './verify-stored.mjs';
const token = readFileSync('good.txt', 'utf8').trim();
const [jwk] = JSON.parse(readFileSync('.well-known/browser-verdict-jwks.json', 'utf8')).keys;
const [h, p, s] = token.split('.');
const claims = JSON.parse(Buffer.from(p, 'base64url').toString());
const tampered = `${h}.${Buffer.from(JSON.stringify({ ...claims, alarm: 'high' })).toString('base64url')}.${s}`;
const otherKey = { ...jwk, x: generateKeyPairSync('ed25519').publicKey.export({ format: 'jwk' }).x };
const at = (seconds) => new Date((claims.iat + seconds) * 1000).toISOString();
const cases = {
good: [token, jwk, 'ref-1', at(60)],
tampered: [tampered, jwk, 'ref-1', at(60)],
'other key': [token, otherKey, 'ref-1', at(60)],
'other session': [token, jwk, 'ref-2', at(60)],
'late decision': [token, jwk, 'ref-1', at(3600)],
};
for (const [name, [t, key, sessionRef, decidedAt]] of Object.entries(cases)) {
try {
verifyStoredToken(t, { jwk: key, sessionRef, decidedAt });
console.log(name, 'accepted');
} catch (e) {
console.log(name, 'rejected:', e.message);
}
}
# check_stored.py: run with python3 check_stored.py
import base64
import json
from datetime import datetime, timezone
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from verify_stored import verify_stored_token
def b64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=")
token = open("good.txt").read().strip()
jwk = json.load(open(".well-known/browser-verdict-jwks.json"))["keys"][0]
h, p, s = token.split(".")
claims = json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)))
tampered = ".".join([h, b64(json.dumps({**claims, "alarm": "high"}).encode()), s])
other_x = Ed25519PrivateKey.generate().public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
other_key = {**jwk, "x": b64(other_x)}
def at(seconds: int) -> datetime:
return datetime.fromtimestamp(claims["iat"] + seconds, tz=timezone.utc)
cases = {
"good": (token, jwk, "ref-1", at(60)),
"tampered": (tampered, jwk, "ref-1", at(60)),
"other key": (token, other_key, "ref-1", at(60)),
"other session": (token, jwk, "ref-2", at(60)),
"late decision": (token, jwk, "ref-1", at(3600)),
}
for name, (t, key, session_ref, decided_at) in cases.items():
try:
verify_stored_token(t, key, session_ref, decided_at)
print(name, "accepted")
except Exception as e:
print(name, "rejected:", e)
The output shows good accepted, and the other four cases each rejected.
For your auditor
Give your auditor:
- The records under review, each with its
token. - The Octet keys you kept.
verify-stored.mjsorverify_stored.py, or a link to this page so they can write their own check from the rules in step 3.
The auditor needs no Octet account, and no access to your systems beyond these files.
Confirm each key
A token that verifies with a key taken only from your records does not show that Octet signed it, because whoever could replace a token could also replace the key stored with it. The auditor confirms each key against Octet before relying on it:
- Fetch
https://geo.octetproof.com/.well-known/browser-verdict-jwks-archive.jsondirectly, find the entry with the stored key'skid, and compare its key values with the stored key. - Check that the token's
iatfalls between the entry'soctet_not_beforeandoctet_not_after, allowing 60 seconds either side. The current key has nooctet_not_after. - If the entry has
"octet_status": "revoked", distrust any token whoseiatis after itsoctet_revoked_at.
The archive is served by the same Octet server that serves the key set. To confirm a key without relying on that server, the auditor checks it against the manifest signed by Octet's root keys, using the root keys pinned in Check a key against Octet's root keys. The manifest's verdictKeys carries the same dates as the archive, so the checks in steps 2 and 3 can use the manifest's entry instead.
Compare with your application
For a sample of records, the auditor compares the user and action in each record with your application's own logs. This comparison links each verified verdict to a user and a decision in your application. The token doesn't carry that link.