Auth overview

Every app that has users has the same afternoon in front of it: a sign-in screen, a way to remember who is signed in, a way to sign them out, and — a week later — the discovery that the mobile app needs a different credential from the web one.

@pithy-sh/auth is that afternoon, done, in your own Worker. Magic link, email one-time code, Google, Apple, GitHub and Facebook. Sessions, short-lived JWT access tokens, a device registry, and two tiers of rate limiting. It is built on Better Auth, and it fills core’s identity seam so that every other capability can write requireAuth() without knowing anything about how a token is checked.

There is no password

There never will be. Email-and-password is never enabled, and that is a security stance rather than a missing feature.

A user proves who they are with a magic link, a one-time code, or a social provider. There is no password database to leak, no password to phish, and no reset flow to build — which also means no forgot password email, no password strength rules, and no argument about bcrypt cost factors.

If your requirements name password sign-in, this capability says no rather than not yet.

The token model, in one paragraph

A successful sign-in mints a session — the long-lived refresh credential. On mobile it lives in secure device storage; on web it is a CSRF-protected cookie.

The session is not what you put on every request. Your app exchanges it for a short-lived JWT access token — 15 minutes, EdDSA — and sends that as Authorization: Bearer <jwt>. The Worker verifies it locally against the JWKS it publishes, so a request costs no database round-trip. When the access token expires, mint another from the session. When the session expires, sign in again.

The token model works through why it is two credentials rather than one, and JWKS and local verification covers the half that makes it cheap.

Mobile and web are both first-class

Mobile uses bearer. There is no ambient credential to forge, so it is CSRF-exempt.

Web has two options: the same bearer flow, for a SPA that holds the token in memory; or a cookie session, CSRF-protected through an origin check and a SameSite=lax cookie. When cookie mode is on, CSRF protection is on with it. That pairing is not configurable apart, deliberately — a cookie session with CSRF disabled is a vulnerability with a config key in front of it.

Mobile reads the session token from a response header on sign-in and stores it. Web gets the session as a cookie automatically.

What you get without writing any of it

  • Six sign-in methods. Magic link and one-time code are built in and fixed. Google, Apple, GitHub and Facebook each turn on with a config flag and a credential you take from that provider’s console.
  • A device registry. Opt in per request by sending device metadata headers at sign-in, and the session binds to that device. Two routes follow: list my devices, and revoke one — sign me out on that lost phone is a single call.
  • Two tiers of rate limiting. Cloudflare’s native rate limiter caps requests per client IP at the edge, in front of every auth route, with no storage round-trip. Better Auth’s own D1-backed limiter caps per action and identity — the magic-link and one-time-code caps. Two limiters because they do two jobs, and because in-memory limiting is per-isolate on Workers and therefore useless.
  • A management surface, default-denied. Six admin routes for a dashboard’s user panes, each behind its own scope. With the control-plane seam not composed they all answer controlplane/not_connected, and no app session opens any of them whatever it carries.

What it deliberately does not do

No impersonation. Sign in as this user mints a credential indistinguishable from the person’s own, so every action taken with it reads in the audit trail as theirs. It is excluded on purpose and it is not reachable by composing what is here — no route mints a session, and no read projects a session token. If it is ever built it gets its own design and its own security review.

No roles or permissions of its own. Auth answers who is this. What they may do is your application’s question, or payments’ entitlement seam, or an organization plugin you compose yourself.

No user profile beyond identity. Your users’ names, avatars and preferences are your tables. Auth owns identity, sessions, accounts, verifications, JWKS, rate limits and devices — all prefixed pithy_auth_*, sitting in the same database as your own tables and joinable with plain SQL.

Five admin scopes, not one admin flag. Reading a user is a privacy operation; revoking their sessions is an availability one. A support tool that looks people up should never be able to sign the whole customer base out, and an incident-response tool that kills a stolen session has no business reading every address in the user table.

It is Better Auth, and the ecosystem is open

Four plugins are composed and fixed — bearer, JWT, magic link, one-time code — and they are composed first. They are fixed because the rest of the kit depends on them and cannot see your config: magic link and one-time code are the sign-in this product promises, JWT mints the JWKS every Worker verifies against, and bearer is how a mobile client presents its credential. A config that names one of the four is refused by name.

Everything else composes. organization, passkey, twoFactor, apiKey, admin, a generic OAuth provider — pass any of them in and pithy migrate creates whatever tables they declare, contributed as ordinary migrations with tested rollbacks. pithy doctor prints every composed plugin and the tables it introduced, because an extension has no package.json for the capability listing to name it from and it still adds routes and tables.

When you would reach for it

Almost always, and almost first. Storage objects belong to an owner, ledger balances belong to a player, ratings bind to a player, and payments has no public routes at all — each of those denies every request without auth composed.

The exception is a backend with no end users at all: a machine-to-machine API, or an internal service reached with a bearer token you issue yourself. Choosing a client credential covers that shape.

What it needs

It requires secrets — its session signing key is read through the registry rather than an environment literal — and email, because magic-link and one-time-code delivery enqueues a durable job rather than sending inline. Both compose automatically with pithy add auth --with-prerequisites.

It gets better with turnstile, which auto-gates the magic-link and one-time-code send routes with zero configuration, and with audit, which records sign-in, token refresh and device revocation when composed.

ESC