Server-authoritative writes

Some writes belong to your backend and never to a device. A score, a match result, a balance. auth decides, per request, whether the caller is your backend, and this page is how.

What a scope is

Six routes in the kit check one of four scopes on c.var.auth.scopes:

ScopeCapabilityWhat it opensOff switch
leaderboard:submitLeaderboardPOST /leaderboard/:boardserverAuthoritative: false
leaderboard:adminLeaderboardHide and remove another player’s entryNone
rating:recordRatingPOST /rating/games/:game/outcomesserverAuthoritative: false
ledger:adminLedgerCredit and debit another player’s balanceNone

A scope is an attestation about the caller, not a permission granted to the user. It says this write came from your trusted backend. It is not a role. Tenancy roles live on c.var.acting, from the organization capability. Management access belongs to the control plane, with its own credential.

Every gate fails closed. With nothing to fill the array, each one refuses every caller. pithy doctor names each gate in that state, per Worker, on its scopes line.

The seam: resolveScopes

// apps/api/pithy.config.ts
auth({
  baseURL: PUBLIC_ORIGIN,
  resolveScopes: async (c) =>
    (await verifyCallerRequest(c, { audience: PUBLIC_ORIGIN })).trusted ? ["leaderboard:submit", "rating:record"] : [],
}),

The session middleware calls it on every authenticated request, after the session resolves. What it returns becomes c.var.auth.scopes. It is handed the request context and the session, { userId, sessionId }.

  • Per request, never cached. A cached scope is a revocation that has not taken effect.
  • Closed on failure, never fatal. A resolver that throws, or answers anything but a list of strings, leaves the request with no scopes. It logs at warn, and the request goes on to its gate, which refuses it.
  • No default. Without a resolver the array is empty and nothing extra runs.
  • Never from what the request says. Not a header like x-scopes, not a body field, not a query parameter. Only from what the request proves.

The session is the player’s, and the backend forwards it. A gated write is still an authenticated request, and the route acts for the session’s user. POST /leaderboard/:board files the score under that user and takes no player in its body. So the backend relays the player’s own session on Authorization and adds its proof beside it. A backend that called with a session of its own would put every score under its own account. Rating outcomes, ledger credits and the admin routes name their target in the body or the path, so any session works for them.

The proof is not bound to that session. A caller token, a certificate or a binding says the request came from your backend. It does not say which player the backend meant. So the backend forwards only the session of the player the write is for.

Three ways to prove the caller

In order. Use the first one you can.

1. A service binding, with no credential at all

If your trusted backend is a Worker, call over a service binding. There is no public network path, so there is nothing to steal, expire or rotate. Where you can use this, the other two are unnecessary.

A request that arrives over a binding carries no mark of it, so the boundary is the Worker itself. Compose the gated capabilities into a Worker with no public address, and share the database by declaring the same DB binding. In its wrangler.jsonc, for each environment:

// apps/internal/wrangler.jsonc
"workers_dev": false,
"preview_urls": false,
// and no "routes", no "domains" in pithy.config.ts, no vars.BASE_URL

All three, set explicitly. pithy deploy --env and pithy doctor read that shape as a Worker reachable only over a binding, and let it through with no origin. Leave one out and deploy refuses the Worker for having no origin, as it refuses any other.

Nothing but a binding can reach that Worker, so its resolver answers for every request it receives:

// apps/internal/pithy.config.ts — reachable only over a service binding
const INTERNAL_ORIGIN = "https://internal.invalid";

auth({ baseURL: INTERNAL_ORIGIN, resolveScopes: () => ["leaderboard:submit", "rating:record", "ledger:admin"] }),

INTERNAL_ORIGIN is the URL the caller fetches over the binding. A binding routes by its name, never by hostname, so the host is yours to pick. .invalid is reserved and resolves nowhere, so nothing outside the binding reaches it by mistake. Use the same origin on both sides. The forwarded session travels on Authorization, so the CSRF origin check does not apply.

The caller declares { type: "service", name: "INTERNAL", service: "internal" }. Give that Worker a route later and the resolver above trusts the internet, so the two decisions stay together.

2. mTLS, refused at the edge

A client certificate is sender-constrained: a stolen token is useless without the private key. Cloudflare-managed client certificates are available on every plan. They need a proxied zone and a hostname that matches the certificate exactly. No wildcards, so not workers.dev.

The zone rule is the gate. Create a WAF custom rule on the hostname, action Block:

(http.host eq "api.example.com" and (not cf.tls_client_auth.cert_verified or cf.tls_client_auth.cert_revoked))

Both halves. A revoked certificate whose chain still verifies reports cert_verified true, so the first half alone lets it through. The rule runs before the Worker, so a caller without a verified, unrevoked certificate never reaches your code. Scope it to the paths only your backend calls if devices share the hostname.

The Worker helper is defense in depth, not the gate. readClientCertificate reads request.cf.tlsClientAuth, and isTrustedCertificate checks it against pins. On its own it blocks nothing.

import { isTrustedCertificate, readClientCertificate } from "@pithy-sh/auth/src/caller/certificate";

resolveScopes: (c) =>
  isTrustedCertificate(readClientCertificate(c.req.raw), { fingerprintsSHA256: [GAME_SERVER_CERT] })
    ? ["leaderboard:submit"]
    : [],

The helper answers four states: absent, unverified, revoked and verified. Only a verified, unrevoked certificate is trusted, and only against a fingerprint pin. Without one it trusts nothing, because every certificate your zone’s CA issued would pass. An issuer pin is an extra check, never a substitute: on the Cloudflare-managed CA every certificate the zone verifies names the same issuer.

3. A short-lived token bound to this origin

For a backend that is not a Worker, on workers.dev or any other host. A token signed with a shared key, minted for this Worker’s origin, alive for minutes. A Worker of your own project does not need it: it calls over a service binding.

Declare the key in the project registry, so pithy secrets knows it:

import { callerTokenRegistry } from "@pithy-sh/auth/src/caller/request";

secrets({ registry: { ...authSecretsRegistry, ...callerTokenRegistry } }),

You make the signing key, not the kit. Two parties hold one value: this Worker, which verifies, and your backend, which signs. Nothing in the kit hands a stored value back out, so a key the kit minted is one your backend could never learn. pithy secrets provision does not create it.

  1. Generate the value where your backend’s secrets live: openssl rand -base64 32. The 44 characters it prints are the key.
  2. Store that text in your backend’s own secret manager, as it was printed.
  3. Pipe the same value into each environment: printf '%s' "$KEY" | pithy secrets create auth-caller-token-key --env prod. In dev, write it into the dev secrets file with pithy secrets edit.

Rotation is a cutover, and it is yours. pithy secrets update replaces the value, and the backend signs with kid: "1". A token signed with the old key is refused from the moment the Worker holds the new one, so change the Worker’s copy and the backend’s back to back, and retry a refused write once the backend holds the new key. pithy secrets rotate does not invent a value here.

Mint on the backend and send the token on the pithy-caller header, beside the player’s session:

import { mintCallerToken } from "@pithy-sh/auth/src/caller/token";

const token = await mintCallerToken({ key: CALLER_TOKEN_KEY, kid: "1", audience: API_ORIGIN });
await fetch(`${API_ORIGIN}/leaderboard/weekly`, {
  method: "POST",
  headers: { authorization: `Bearer ${playerSession}`, "pithy-caller": token, "content-type": "application/json" },
  body: JSON.stringify({ score }),
});

mintCallerToken is a convenience. Any language can build the token, because it is a compact JWS:

PartWhat it must be
EncodingThree segments, header.claims.signature, each unpadded base64url
Header{"alg":"HS256","typ":"JWT","kid":"1"}. alg must be HS256, and kid is required
Claimsaud is one string, the origin of the Worker the token is for. iat and exp are whole Unix seconds, exp at most 900 seconds after iat
SignatureHMAC-SHA-256 over the ASCII bytes of header.claims
KeyThe UTF-8 bytes of the stored string, not the 32 bytes it decodes to

verifyCallerRequest refuses an expired token, a token minted for another origin, a token signed by a key the store does not hold, and anything that is not a well-formed HS256 compact JWS, each on its own. A token lives five minutes by default and fifteen at most, and one claiming longer is refused even when everything else checks out.

What not to do

Do not compare a long-lived shared secret in a header. It is the shape everyone reaches for, and it fails every control RFC 9700 names at once: it is not least-privilege, it is not bound to an audience, it does not expire and it is not bound to its sender. One leak is permanent until somebody notices.

Do not grant a scope from the user. A player’s account is not a trusted backend because of who owns it. Decide from the request, every time.

ESC