You need: auth composed. That is the whole prerequisite.
One import, one middleware
Routing and verification is what the middleware resolves, entitlements is the gate that stacks after it, and adding a route is where a handler comes from.
import { requireAuth } from "@pithy-sh/auth";
app.get("/notes", requireAuth(), async (c) => {
const rows = await c.var.db.app
.selectFrom("notes")
.where("authorId", "=", c.var.auth.userId)
.selectAll()
.execute();
return c.json({ notes: rows });
});c.var.auth carries the resolved identity — the user id, and the session and device ids. It is null until a verification strategy sets it, and requireAuth() is what turns that null into a 401 before your handler runs.
So inside the handler it is not null, and you are not writing a check.
Every other capability gates the same way
None of them validates a token. They all read the same identity seam, which is why adding payments or storage later needs no auth wiring from you: the seam is already filled.
That is also what makes the absence coherent. With auth not composed, the seam is null and those routes deny — a defined behavior rather than a crash.
Gating on an entitlement
app.get("/reports", requireAuth(), requireEntitlement("pro"), handler);Identity first, then entitlement. You cannot ask what somebody is entitled to before you know who they are.
requireEntitlement lives in core rather than in the payments package, on purpose: a route should be able to say what it requires without importing the capability that provides it. With no provider composed, the seam denies — a gate with no provider fails closed rather than standing open.
That creates one failure worth knowing about. A Worker gating on entitlements while composing no provider is not broken, it is silently paywalled shut, and the runtime cannot tell that mistake from a legitimately unentitled user. So the CLI answers it instead: pithy doctor and pithy dev compare the gates in your source against what the Worker composes, and say so at startup rather than leaving you a fleet of production 403s.
Name the entitlement key, never a product. pro, not pro_monthly — that is the distinction the whole payments model rests on.
The full stack, in order
app.post(
"/reports",
requireAuth(),
requireEntitlement("pro"),
zValidator("json", CreateReport, validationHook),
handler,
);Identity, entitlement, request contract, handler. The order is not cosmetic.
A validator ahead of a gate turns a 401 into a 400 — and tells an unauthenticated caller which requests were well-formed. On an admin surface that is an oracle rather than a courtesy.
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, so a bad request renders as validation/invalid_input through the same error path as everything else — one failure format, whatever failed.
Stacking a humanity check
Turnstile is not a verification strategy. A strategy answers who is this; a humanity check answers is this a human. Different questions, so it stacks on top rather than replacing anything:
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 are kept apart.
Same-origin, for a cookie session
Where you accept a cookie session, a state-changing route wants the same-origin gate beside the auth one:
app.post("/organizations", requireAuth(), requireSameOrigin(), zValidator(…), handler);The gate arrives already bound to your Worker’s trusted origins by whichever capability resolved them — you get the decision rather than the material to rebuild it, because handing over the list is how a Worker ends up with two same-origin implementations free to disagree, and the weaker one is then its real policy.
Declaring a route public
A route with no strategy is not the same as a route with public. public is a positive declaration that this route is open on purpose, and it reads as one in review.
Nobody wrote a gate here and we decided this needs no gate look identical in code and are completely different facts.
What a caller sees
401 — the credential is missing, expired or invalid. Mint a fresh access token from the session; if that fails, sign in again.
403 — the caller is who they say and may not do this. Do not retry.
A client that treats those the same loops: it refreshes a perfectly good token, gets another 403, and refreshes again.
Check it worked
- The route answers for a signed-in caller
- It 401s with no credential, and with an expired one
- With an entitlement gate: it 403s for somebody without the key, and
pithy doctorreports no gap - With a validator: a malformed body gets a 400 carrying the failing field