# 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 act on each `decision_ref` for one request only. Together with the checks below, that refuses an old or reused receipt.

If your backend asks the engine again with the same `decision_ref`, the engine decides again from the same proofs and signs a new receipt. Keep the latest receipt for that `decision_ref`, and don't count it as a new check.

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`). Skip this check only when you re-check an archived receipt later
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 `policy_version` too, if you pin one), and `license_id` is yours
6. `env` is `release`. A `sandbox` receipt comes from a simulator, an emulator or a debug build. Accept it only in your test environment

![A fresh receipt passes all six checks and your backend enforces it. The same receipt replayed for another request fails the decision_ref check and is treated as deny.](/docs-art/magistrate/receipt-check.svg){: .docs-anim }

<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 !== "release") throw new Error("not a release 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") != "release":
        raise ValueError("not a release 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. When you check an archived receipt against the history, check its chain too: entry 0 is signed by its own key, and each later entry by the key before it.

## 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. The app asks your backend for a check**, and sends `challengeEnv` with the request. It is `"sandbox"` when the SDK signed in with a sandbox token, and nil or null otherwise.

**2. Your backend asks for a challenge.**

```
POST https://gw.octetproof.com/v1/decide/challenge
{ "policy": "p_...", "env": "sandbox" }
```

Send `"env": "sandbox"` only when the app sent it. Without it, a sandbox build's check looks for production proofs, finds none and comes back UNKNOWN, with `cause` `absence`.

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

**3. 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 env = magistrate.challengeEnv(license: license)   // "sandbox" or nil
let nonce = try await api.startCheck(env: env)
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 env = magistrate.challengeEnv(license)   // "sandbox" or null
val nonce = api.startCheck(env)
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>

**4. 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": { ... }
}
```

`license_id` is always required: the engine checks the proofs against the apps registered on your license. 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. The engine learns the key only from a decide that carries the proofs, as in step 4. A decide without `proofs`, where the engine fetches them itself, ignores `attestation_enrolment`, so an iPhone's first check must carry its proofs. 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.
