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 for 2 minutes and keeps no copy after that, so your records are the only copy.
1. Keep every Octet key you verify with
Octet's key set lists only the key Octet signs with today. After Octet moves to a new key, the old key leaves the key set, and tokens signed with it can't be checked against the published keys. Keep your own copy: the first time a token carries a kid you haven't stored, store that key's entry from the key set.
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. The token can't. |
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. |
userNet |
clientNetHash of the IP address your backend saw |
Lets an auditor compare your view of the user's network with clientNet, without you storing the IP address. Optional. |
Store token exactly as Octet returned it. Any change to the string, including re-encoding it, makes the signature fail.
In Node.js:
import { clientNetHash, 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(),
userNet: clientNetHash(userIp),
});
In Python:
from datetime import datetime, timezone
from verify_verdict import client_net_hash, 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(),
user_net=client_net_hash(user_ip),
)
auditLog, user, userIp 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 has always passed 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. Save this as verify-stored.mjs:
// verify-stored.mjs: check a stored Octet verdict token. Node.js 18 or later, no dependencies.
import { createPublicKey, verify } from 'node:crypto';
const TYP = 'octet-browser-verdict+jwt;v=1';
const LEEWAY_S = 30; // allowed clock difference between your servers and Octet's, in seconds
const decode = (part) => JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
/** The kid in a token's header. */
export const kidOf = (token) => decode(token.split('.')[0]).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 }) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('malformed token');
const [h, p, s] = parts;
const header = decode(h);
if (header.alg !== 'EdDSA' || header.typ !== 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 key = createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x }, format: 'jwk' });
if (!verify(null, Buffer.from(`${h}.${p}`), key, Buffer.from(s, 'base64url'))) {
throw new Error('bad signature');
}
const claims = decode(p);
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. Save this as verify_stored.py:
# verify_stored.py: check a stored Octet verdict token. Python 3.9 or later.
# pip install "pyjwt[crypto]"
from datetime import datetime
import jwt
TYP = "octet-browser-verdict+jwt;v=1"
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."""
header = jwt.get_unverified_header(token)
if header.get("alg") != "EdDSA" or header.get("typ") != TYP:
raise ValueError("not an Octet verdict token")
if header.get("kid") != jwk.get("kid"):
raise ValueError("key id does not match the token")
claims = jwt.decode(
token,
jwt.PyJWK(jwk).key,
algorithms=["EdDSA"],
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 record has a userNet and the claims have a clientNet, compare the two. A mismatch 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.mjs, 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 key taken only from your records shows nothing on its own, 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:
- The
kidis in Octet's key set. Fetchhttps://geo.octetproof.com/.well-known/browser-verdict-jwks.jsondirectly and comparexwith the stored key. - The
kidis no longer in Octet's key set. Send thekidandxto developer@octetproof.com, and Octet replies with whether it was an Octet verdict key and when it was in use.
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. That comparison is what links a verified verdict to a real decision about a real user.