# 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](#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

<Tabs groupId="lang">
<TabItem value="node" label="Node.js">

Uses [`jose`](https://github.com/panva/jose).

```javascript
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);
}
```

</TabItem>
<TabItem value="python" label="Python">

Uses [PyJWT](https://pyjwt.readthedocs.io/) with the `cryptography` package.

```python
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", []))
    )
```

</TabItem>
</Tabs>

## 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](/docs/magistrate/reference/decision/) 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.

<Tabs groupId="platform">
<TabItem value="ios" label="iOS">

```swift
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)
```

</TabItem>
<TabItem value="android" label="Android">

```kotlin
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)
```

</TabItem>
</Tabs>

**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](/docs/magistrate/reference/decision/) 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.
