Routing and verification

Every route declares how its caller is verified. There is no implicit auth.

That is the rule, and the whole of this page is what the five answers mean and which questions they do not answer.

The five

The Worker contract is what mounts them, auth is the only capability implementing two, HTTP routes lists which route uses which, and protecting a route is the walkthrough.

StrategyVerifies
bearerA short-lived access token, over Authorization: Bearer. Validated locally against the published JWKS
sessionA cookie-based web session, CSRF-protected
signed-webhookThe sender’s own proof over the exact received bytes
control-planeA management client’s scoped credential. Default-denied
publicNothing. The route is open

bearer and session are implemented by @pithy-sh/auth, and it is the only capability that implements them. Every other capability gates routes with requireAuth() and reads identity off the seam — none of them validates a token itself.

public is a decision, not an absence

A route with no strategy is not a route with public. public is a positive declaration that this route is open on purpose, and it reads as one in review.

The distinction matters because nobody wrote a gate here and we decided this needs no gate look identical in code and are completely different facts.

signed-webhook is one name over several mechanisms

An HMAC over the raw body. A signed JWS chain. An OIDC token. Which one is used is the sender’s choice, not yours — Apple, Google, Stripe, Paddle and Lemon Squeezy each prove authenticity their own way.

So the strategy names the shape — the sender authenticates the exact bytes you received — and each rail implements its own check. The timestamped-HMAC form ships in core because it is the most common; a rail that proves it differently brings its own.

The exact received bytes part is load-bearing. A signature over a re-serialized body verifies the wrong thing, and a webhook handler that parses before it verifies has already lost.

control-plane is denying by default

A Worker that composes the seam and has never been connected answers every control-plane route with controlplane/not_connected. Not 404, not 403 with a hint — a specific code that says nothing is registered here.

There is no flag to leave off and no backdoor. Control plane overview is the model.

The verified caller lands on its own request variable, never on the identity one. That is what stops a control-plane call from satisfying any capability’s requireAuth() — which would be a scope escalation across the whole route tree rather than a bug in one place.

Turnstile is deliberately not on that list

A verification strategy answers who is this?

A humanity check answers is this a human?

Those are different questions, so a humanity check can never be a route’s identity gate. turnstile is middleware that stacks on top of whatever the route already declares:

app.use("/signup", turnstile());

A public signup route that still requires a token is a coherent thing, and it is only expressible because the two questions are kept apart.

Every route also declares a request contract

This is as mandatory as the strategy, and it belongs on the same line. How a caller is verified and what a caller may send are two halves of one declaration:

app.post("/notes", requireAuth(), zValidator("json", CreateNote, validationHook), (c) => {
  const body = c.req.valid("json");
});

Never parse input inside a handler. A handler takes typed values, so the route signature carries the whole contract — and a reader can see what a route accepts without reading its body.

The validation hook is the only hook, and it exists so a bad request renders as validation/invalid_input through the same error path as everything else, rather than through the validator’s own response shape. One failure format, whatever failed.

Gates stack, and the order matters

app.get("/reports", requireAuth(), requireEntitlement("pro"), zValidator("query", Filters, validationHook), handler);

Identity, then entitlement, then the request contract. A validator ahead of a gate turns a 401 into a 400 and tells an unauthenticated caller which requests were well-formed — which on an admin surface is an oracle rather than a courtesy.

flowchart LR
    R["Request"] --> A{"requireAuth()"}
    A -- fails --> A4["401"]
    A -- passes --> E{"requireEntitlement()"}
    E -- fails --> E4["403"]
    E -- "no provider composed" --> E4
    E -- passes --> V{"zValidator()"}
    V -- fails --> V4["400"]
    V -- passes --> H["Your handler"]

Put the validator first and an unauthenticated caller gets the 400 — which is the oracle.

requireEntitlement lives in core rather than in the payments package, for the same reason requireAuth is read off a seam: a route should be able to say what it requires without importing the capability that provides it. With no provider composed, the entitlement seam denies — a gate with no provider fails closed rather than standing open.

What a 401 means to your client

A missing or invalid credential. Mint a fresh access token from the session; if that fails, sign in again.

A 403 is different and should be read differently: the caller is who they say they are and may not do this. Retrying will not help, and a client that treats the two the same will loop.

ESC