Protect a route

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.

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 doctor reports no gap
  • With a validator: a malformed body gets a 400 carrying the failing field
ESC