Sign in on the web

You need: auth composed, which brings email and secrets with it.

Time: ten minutes, and most of it is already done if you scaffolded a front end.

Two credential shapes, and you pick one

The token model is what each shape is, signing in on iOS and Android is the other half, and protecting a route is what the credential is for.

A cookie session. The browser holds it automatically, and CSRF protection comes with it.

A bearer token, the same flow mobile uses, for a SPA that holds the token in memory.

Cookie is the default for a web app and is what the scaffolded screens use. Bearer is right when your front end is on a different origin, or when you already have token plumbing.

1. Somebody types their address. Your form posts it:

await fetch("/auth/sign-in/magic-link", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ email, callbackURL: "/" }),
});

The route enqueues a durable mail job and returns. It does not send inline, so the response comes back in milliseconds whether or not the message has left.

2. They click the link. It lands on your Worker, which verifies the token, mints a session, sets the cookie, and redirects to the callback.

3. They are signed in. Nothing else to do.

The one-time-code flow is the same shape with a code they type instead of a link they click. Both are built in and neither can be disabled — they are the sign-in this kit promises, because there is no password to fall back on.

Reading the session

From your own routes, the identity is on the request context:

app.get("/me", requireAuth(), (c) => c.json({ userId: c.var.auth.userId }));

From the browser, the scaffolded session hook wraps it. If you are writing your own, one route returns the current session, and a 401 means not signed in rather than something broke.

Trusted origins

Every web origin and mobile deep-link scheme that may be a redirect target and a CSRF origin:

auth({
  trustedOrigins: ["https://app.example.com", "myapp://"],
}),

Dev needs neither this nor a base URL. It resolves its own address and trusts its own origin — because local dev has no TLS and its port is assigned per run, which makes it the one address nobody can write down.

Signing out

The session is revoked server-side, not just forgotten client-side. That distinction matters: a client that deletes its own cookie has ended a browser’s session, and a session that is still valid is still valid.

What a 401 means to your client

Mint a fresh access token from the session. If that fails, the session has expired and they sign in again.

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

Sending mail in development

pithy dev sends real mail by default, through Cloudflare Email Service with the same DKIM as production — which needs a Cloudflare login and an onboarded sending domain.

Without those, it falls back to the local simulator, says so in its ready banner, and writes the rendered message to disk. So the magic link is readable either way, and which one you got is said once where you look.

The faster local loop

Round-tripping a magic link for every local sign-in gets old within an hour.

pithy seed
pithy dev

Then press l. It opens your default browser already signed in as a seeded user.

No session cookie is ever printed — not to the terminal, not to the log. It used to be, as a line to paste into a browser console, and a working session token rendered as text is a working session token at rest in a scrollback, a log and a screenshot.

The route that does it exists only in a dev composition and never under CI, gated on both conditions independently at registration — because it mints an authenticated session with no credential presented, so neither gate is allowed to imply the other.

You opt in per machine by naming a seeded user in a file in the config directory. No file, no session.

Bot-gating the send routes

Compose turnstile and the magic-link and one-time-code send routes are gated automatically, with no wiring at all.

Those two are the routes a bot most wants — one sends mail on demand to any address it names. Bot-gate a public route is the general form.

Check it worked

  • The send route returns quickly and a job row appears
  • The link signs you in and the cookie is set
  • A protected route answers for you and 401s in a private window
  • Signing out makes the same route 401
ESC