Docs/Octet Browser/Guides/Verify the Signed Token

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, and 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.

Format

The token is a compact JWS (RFC 7515) signed with Ed25519 (alg EdDSA, RFC 8037). Any JWT library that supports EdDSA can verify it.

The header is always:

{ "alg": "EdDSA", "typ": "octet-browser-verdict+jwt;v=1", "kid": "..." }

Check typ exactly. It is what 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.
clientNet string SHA-256, in hex, of the user's network as your edge saw it. See Check the user's network.
modelVersion string An opaque version string for the Octet release that produced the verdict.

The claims repeat the verdict fields beside the token in the same response. After verifying, use the claims and not the unsigned fields.

The key set

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

https://geo.octetproof.com/.well-known/browser-verdict-jwks.json
{
  "keys": [
    { "kty": "OKP", "crv": "Ed25519", "use": "sig", "alg": "EdDSA", "kid": "...", "x": "...", "octet_purpose": "browser-verdict" }
  ]
}

Pick the key whose kid matches the token header. 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.

What to check

  1. The header's alg is EdDSA and its typ is octet-browser-verdict+jwt;v=1.
  2. The signature verifies with the key for the header's kid.
  3. iss is octet-browser.
  4. exp has not passed. Allow up to 30 seconds of clock difference.
  5. sessionRef equals the sessionRef you created for this user's session. This stops a valid token from another session being replayed as this one.

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 { createHash, createPublicKey, verify } from 'node:crypto';
import { isIPv4, isIPv6 } from 'node:net';

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

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

async function getKey(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 createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x }, format: 'jwk' });
}

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

/** 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 [h, p, s] = parts;
  const header = decode(h);
  if (header.alg !== 'EdDSA' || header.typ !== TYP) throw new Error('not an Octet verdict token');

  const key = await getKey(header.kid);
  if (!verify(null, Buffer.from(`${h}.${p}`), key, Buffer.from(s, 'base64url'))) {
    throw new Error('bad signature');
  }

  const claims = decode(p);
  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;
}

/** The value of the clientNet claim for an IP address, or undefined if it isn't one. */
export function clientNetHash(ip) {
  const addr = ip.replace(/^::ffff:/i, '');
  let prefix;
  if (isIPv4(addr)) {
    prefix = `${addr.split('.').slice(0, 3).join('.')}.0/24`;
  } else if (isIPv6(addr)) {
    const halves = addr.split('%')[0].toLowerCase().split('::');
    const head = halves[0] ? halves[0].split(':') : [];
    const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
    const groups = [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail];
    prefix = `${groups.slice(0, 3).map((g) => parseInt(g, 16).toString(16)).join(':')}::/48`;
  } else {
    return undefined;
  }
  return createHash('sha256').update(prefix).digest('hex');
}

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. Install it with pip install "pyjwt[crypto]" and save this as verify_verdict.py:

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

import jwt

JWKS_URL = os.environ.get(
    "OCTET_JWKS_URL", "https://geo.octetproof.com/.well-known/browser-verdict-jwks.json"
)
TYP = "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 verify_verdict_token(token: str, expected_session_ref: str) -> dict:
    """Returns the verified 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")
    key = _jwks.get_signing_key(header["kid"])
    claims = jwt.decode(
        token,
        key.key,
        algorithms=["EdDSA"],
        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


def client_net_hash(ip: str):
    """The value of the clientNet claim for an IP address, or None if it isn't one."""
    ip = ip.split("%")[0]
    if ip.lower().startswith("::ffff:"):
        ip = ip[7:]
    try:
        addr = ipaddress.ip_address(ip)
    except ValueError:
        return None
    if addr.version == 4:
        prefix = ".".join(str(addr).split(".")[:3]) + ".0/24"
    else:
        groups = addr.exploded.split(":")[:3]
        prefix = ":".join(format(int(g, 16), "x") for g in groups) + "::/48"
    return hashlib.sha256(prefix.encode()).hexdigest()

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

clientNet lets you check that the token was issued for the network your user is on. It is the SHA-256, in lowercase hex, of the user's network prefix as your edge saw it:

  • IPv4: the first three octets followed by .0/24. For 203.0.113.77 that is 203.0.113.0/24.
  • IPv6: the first three groups, in lowercase hex without leading zeros, followed by ::/48. For 2001:0db8:00a1:0042::17 that is 2001:db8:a1::/48.

Both examples above include a helper, clientNetHash in Node.js and client_net_hash in Python, that computes this from an IP address. Compare it with the claim:

const sameNetwork = clientNetHash(userIp) === claims.clientNet;

Don't block on a mismatch 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. clientNet is absent when the edge could not read the user's address.

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=1', 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.