Docs/Magistrate/Get Started/Verify and Enforce the Decision

Verify and Enforce the Decision

Octet decides and signs. Your backend enforces. This page shows how your backend checks a decision receipt and turns it into what happens to the request.

Why the backend, not the app

The app gets the decision first, but a user controls their own device. They can change the app, replay an old answer or skip the check. The receipt is signed by the Octet policy engine with a key the device never has, so your backend can tell a real, current decision from anything else.

1. Send the receipt with the request

Have the app send receipt from the DecisionResult together with the request it protects, for example in the body of the order or the sign-up call. A receipt is valid for 5 minutes after the engine signs it, so ask for the decision right before the request.

2. Know which decision you expect

The receipt's decision_ref identifies the check it answers, and policy names the policy that decided it. Your backend checks both, so a permit from another check or another policy can't be sent instead.

Have the app send decisionRef with the request, and accept each decision_ref only once. Together with the checks below, that refuses an old or reused receipt.

Your backend can also start the check itself, so the decision comes to your backend directly and never passes through the app. See Start the check from your backend.

3. Verify the receipt

A receipt is a compact JWS signed with Ed25519 (alg EdDSA, typ octet-decision-receipt+jwt). The engine's public keys are at:

https://gw.octetproof.com/.well-known/decision-receipt-jwks.json

Each key has a kid, a valid_from and, once it is replaced, a valid_until, all in Unix seconds. A receipt is valid when:

  1. its signature verifies with the key whose kid is in the receipt's header
  2. it hasn't expired (exp)
  3. it was signed inside that key's window. A replaced key still counts for 300 seconds after its valid_until
  4. iss is octet-policy-engine
  5. decision_ref is the one you expected, policy is the policy that protects this request, and license_id is yours
  6. env is prod. A receipt without env is prod. A sandbox receipt comes from a simulator, an emulator or a debug build. Accept it only in your test environment

Uses jose.

import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";

const JWKS_URL = "https://gw.octetproof.com/.well-known/decision-receipt-jwks.json";
const GRACE_S = 300; // a replaced key still verifies for 5 minutes after valid_until

let jwksCache = null;
async function receiptKey(kid) {
  if (!jwksCache || !jwksCache.keys.some((k) => k.kid === kid)) {
    jwksCache = await (await fetch(JWKS_URL)).json(); // refetch on an unknown kid
  }
  const jwk = jwksCache.keys.find((k) => k.kid === kid);
  if (!jwk) throw new Error("unknown receipt key");
  return jwk;
}

// Returns the receipt's claims, or throws. decisionRef is the one your
// backend expected for this request, policy is the policy that protects it,
// licenseId is your license id.
export async function verifyReceipt(receipt, { decisionRef, policy, licenseId, getKey = receiptKey }) {
  const { kid } = decodeProtectedHeader(receipt);
  const jwk = await getKey(kid);
  const { payload: c } = await jwtVerify(receipt, await importJWK(jwk, "EdDSA"), {
    algorithms: ["EdDSA"],
    typ: "octet-decision-receipt+jwt",
    issuer: "octet-policy-engine",
  }); // checks the signature and exp
  if (c.iat < jwk.valid_from || (jwk.valid_until && c.iat > jwk.valid_until + GRACE_S)) {
    throw new Error("receipt signed outside its key's window");
  }
  if (c.decision_ref !== decisionRef) throw new Error("receipt is for another request");
  if (c.policy !== policy) throw new Error("receipt is for another policy");
  if (c.license_id !== licenseId) throw new Error("receipt is for another license");
  if ((c.env ?? "prod") !== "prod") throw new Error("sandbox receipt");
  return c;
}

// The request may go ahead only on a permit without a cause (a cause means
// UNKNOWN), whose every obligation you fulfilled.
export function mayProceed(claims, fulfil) {
  return claims.decision === "permit" && claims.cause == null &&
    (claims.obligations ?? []).every(fulfil);
}

Uses PyJWT with the cryptography package.

import json
import urllib.request

import jwt  # PyJWT, with the cryptography package for EdDSA

JWKS_URL = "https://gw.octetproof.com/.well-known/decision-receipt-jwks.json"
GRACE_S = 300  # a replaced key still verifies for 5 minutes after valid_until

_jwks = None


def receipt_key(kid):
    global _jwks
    if _jwks is None or not any(k["kid"] == kid for k in _jwks["keys"]):
        with urllib.request.urlopen(JWKS_URL) as r:  # refetch on an unknown kid
            _jwks = json.load(r)
    for k in _jwks["keys"]:
        if k["kid"] == kid:
            return k
    raise ValueError("unknown receipt key")


def verify_receipt(receipt, *, decision_ref, policy, license_id, get_key=receipt_key):
    """Returns the receipt's claims, or raises. decision_ref is the one your
    backend expected for this request, policy is the policy that protects it,
    license_id is your license id."""
    header = jwt.get_unverified_header(receipt)
    if header.get("typ") != "octet-decision-receipt+jwt":
        raise ValueError("not a decision receipt")
    jwk = get_key(header.get("kid"))
    c = jwt.decode(
        receipt,
        jwt.PyJWK(jwk, algorithm="EdDSA").key,
        algorithms=["EdDSA"],
        issuer="octet-policy-engine",
        options={"require": ["exp", "iat"]},
    )  # checks the signature and exp
    if c["iat"] < jwk["valid_from"] or (
        jwk.get("valid_until") and c["iat"] > jwk["valid_until"] + GRACE_S
    ):
        raise ValueError("receipt signed outside its key's window")
    if c.get("decision_ref") != decision_ref:
        raise ValueError("receipt is for another request")
    if c.get("policy") != policy:
        raise ValueError("receipt is for another policy")
    if c.get("license_id") != license_id:
        raise ValueError("receipt is for another license")
    if c.get("env", "prod") != "prod":
        raise ValueError("sandbox receipt")
    return c


def may_proceed(claims, fulfil):
    """The request may go ahead only on a permit without a cause (a cause means
    UNKNOWN), whose every obligation you fulfilled."""
    return (
        claims["decision"] == "permit"
        and claims.get("cause") is None
        and all(fulfil(o) for o in claims.get("obligations", []))
    )

4. Enforce

Decide from the verified claims, and only from them:

Claims What it means What your backend does
cause is present, whatever the decision UNKNOWN: the proofs couldn't settle the location, so the policy used its fail-safe answer Your choice. Often: ask the user to try again with location on
decision is permit, no obligations The policy's conditions hold Let the request go ahead
decision is permit, with obligations The policy's conditions hold if you also do something, such as a step-up authentication Go ahead only once you fulfilled every obligation. Treat one you don't recognise as not fulfilled
decision is deny, no cause A rule or the default decided against the request Your choice: refuse, review or log it
No receipt, or it doesn't verify No decision Treat it as deny

mayProceed and may_proceed above let a request go ahead only on a permit with no cause and every obligation fulfilled. Everything else is your policy, not Octet's. See Decision Response for cause and obligations, and how the studio's YES, NO and UNKNOWN map to these claims.

5. Keep the receipt

Octet doesn't give you a way to fetch a receipt later, so store each receipt you act on, with the request it decided. It stays checkable against the public key, and /.well-known/decision-receipt-key-history.json lists every receipt key the engine has used, so a receipt still verifies after its key is replaced.

Start the check from your backend

Instead of calling decide in the app, your backend can run the check itself. The app only makes the proofs, and the decision comes back to your backend.

1. Your backend asks for a challenge.

POST https://gw.octetproof.com/v1/decide/challenge
{ "policy": "p_…" }

The answer has decision_ref, nonce and expires_at. Keep decision_ref, and send nonce to the app.

2. The app makes the proofs for that nonce, and sends them to your backend with its license ID and its enrolment.

let proofs = try await magistrate.prove(license: license, nonce: nonce)
let enrolment = magistrate.attestationEnrolment()   // a JSON string, or nil
try await api.submitProofs(licenseId: license.licenseId,
                           proofs: proofs.compactMap(\.proofBytesBase64URL),
                           enrolment: enrolment)
val proofs = magistrate.prove(license, nonce)
val enrolment = magistrate.attestationEnrolment()   // always null on Android
api.submitProofs(licenseId = license.licenseId,
                 proofs = proofs.mapNotNull { it.proofBytesBase64Url },
                 enrolment = enrolment)

3. Your backend asks for the decision.

POST https://gw.octetproof.com/v1/decide
{
  "policy": "p_…",
  "license_id": "…",
  "decision_ref": "…",
  "proofs": [{ "proof_bytes_b64": "…" }],
  "attestation_enrolment": { … }
}

Send attestation_enrolment as a JSON object, the app's string parsed, not as a quoted string. Leave it out when the app sent none.

The answer is a decision response with its receipt. Verify and enforce it as in steps 3 to 5, with the decision_ref your backend kept.

The enrolment on iPhone. An iPhone's first check needs the enrolment. A proof carries only the device's App Attest assertion, and the engine learns the device's key from the enrolment, once. Without it, a first-time iPhone's check comes back deny with a cause, which is UNKNOWN. Send it every time: the engine ignores it for a device it already knows. On Android, attestationEnrolment() always returns null, because every proof carries its own hardware key chain, so one backend flow serves both. decide and decideMany send the enrolment for you.

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