Gating a route
import { requireAuth } from "@pithy-sh/auth";
app.get("/notes", requireAuth(), (c) => c.json({ userId: c.var.auth.userId }));That is the whole integration for most applications — protecting a route walks it with the failure cases.
c.var.auth carries the resolved identity — the user id, plus the session and device ids. It is null until a verification strategy sets it, and the middleware turns that null into a 401 before your handler runs, so inside the handler it is not null.
Every other capability gates the same way, reading the same seam. None of them validates a token.
Signing somebody in
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.
The one-time-code flow is the same shape with a code they type instead of a link they click.
The token exchange
Web gets a session cookie automatically, and can stop there.
Mobile reads the session token off a response header on sign-in, stores it in secure device storage, and exchanges it for the short-lived access token:
GET /auth/token
Authorization: Bearer <session token>Then sends the returned JWT on everything else. The Worker verifies it locally against the published JWKS, so no request costs a database round-trip.
Refresh on a 401, not on a timer — and single-flight it, so five concurrent 401s produce one refresh rather than five.
Devices
Opt in per request by sending device metadata headers at sign-in:
| Header | Meaning |
|---|---|
x-pithy-device-id | A client-generated stable id. The same physical device maps to one row across re-logins |
x-pithy-platform | ios, android or web |
x-pithy-device-name | A human label |
x-pithy-push-token | The push token, stored for later routing |
The session binds to the device, and two routes follow: list my devices, and revoke one. Sign me out on that lost phone is a single call.
Generate the stable id once and keep it. It is what makes the device one row rather than a new one per sign-in.
Composing more Better Auth plugins
import { organization } from "better-auth/plugins/organization";
auth({
baseURL: PUBLIC_ORIGIN,
plugins: [organization()],
}),Four are composed first and are fixed: bearer, JWT, magic link and one-time code. A config naming one of them is refused by name.
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 key set every Worker verifies against, and it is what the control-plane seam is built on. Bearer is how a mobile client presents its credential.
Removing one is not a preference; it is breaking a contract several packages away. And because plugin endpoints merge by id with the later registration winning, adding one of the four would silently redefine it — which is why a duplicate is refused rather than ignored.
Two plugins sharing an id are refused the same way.
A plugin’s tables are created by migrate
A plugin brings schema. The capability asks the plugin what schema your list implies, subtracts what the fixed four already imply, and contributes one ordinary migration per plugin — with a tested rollback, beside the initial one.
Nothing new was added to the migration model: your plugin list is in the config file migrate already imports.
Three things about the derivation:
A column added to an existing table is nullable, whatever the plugin declares — SQLite refuses otherwise on a table with rows. The plugin writes the value on every insert it makes, so the constraint holds where the plugin enforces it.
Foreign keys are omitted, matching the rest of the kit — the database does not enforce them without a per-connection setting. Linkage is by indexed id columns, and the plugin’s declared indexes are created.
A plugin’s table names are the plugin’s own, not prefixed. They are your tables now. A collision with a table you already own is renamed through the plugin’s own option; a collision with the kit’s is refused at composition time with both names.
Removing a plugin needs the same care as removing a capability
Roll back first, while the plugin is still composed, then take it out of the config.
Otherwise its migration disappears from the set while its row is still in the ledger, and the migrator refuses a chain it cannot account for.
The client half is separate
The client is built from its own plugin list. The server’s type never crosses into a browser bundle — so composing a plugin on the server is half of it, and the matching client plugin goes beside it.
The kit’s own sign-in plugins have client halves too, in the same list. Nothing about the client is inherited from the server.
The one thing that does need the server’s type is the helper that teaches the client about extra user and session fields, and the instance type is parameterized for exactly that.
Composed plugins are reported
pithy doctor prints every composed plugin and the tables it introduced.
A plugin has no package manifest for the capability listing to name it from, and it adds both routes and tables — so it gets a line of its own rather than living only in the source of your config.
The management surface
Six routes for a dashboard’s user panes — named in the reference — each behind its own scope, and every one default-denied: with the control-plane seam not composed they all answer controlplane/not_connected, and no app session opens any of them whatever it carries.
requireAuth() never appears on one — the seam leaves the identity null on purpose, so an auth gate there would deny every legitimate management call forever, and there would be no user to sign in as that could fix it.
Both listings are cursor-paginated, never offset: people sign up while somebody is paging through, and offset would shift rows under them.
Responses are projections, never rows. A session’s token never leaves the Worker — it is the credential. A device’s push token never leaves — it is a capability to reach somebody’s phone. Provider tokens are never even loaded.