Add Auth

pithy add auth --with-prerequisites

The flag is not optional advice. Auth declares two peer capabilities — secrets and email — and createBackend refuses to assemble a Worker missing either, naming the one that is absent. A project without them does not boot at all, so composing them is part of adding auth rather than a separate decision.

They compose deepest first: secrets, then email, then auth. At a terminal you would be asked once for the whole cascade; --with-prerequisites is that answer written down, and anything headless is refused with the exact commands in order.

What lands in your repo

apps/<worker>/pithy.config.ts gains an import and a registration call inside the managed region:

capabilities: [
  // pithy:capabilities (managed region — do not remove this marker)
  secrets(),
  email({ fromAddress: "noreply@example.com", baseUrl: PUBLIC_ORIGIN }),
  auth({ basePath: "/auth", baseURL: PUBLIC_ORIGIN }),
],

PUBLIC_ORIGIN is written unquoted, not a literal. Every capability that needs a public origin asks the same question, and a URL typed into any one of them is one environment’s origin written into all of them — which is how a staging deploy mails real users magic links into production. pithy init scaffolds the derivation; add writes the constant.

apps/<worker>/wrangler.jsonc gains two bindings, in every environment stanza the file declares:

BindingTypeWhat it is
DBd1Where the pithy_auth_* tables live — the same database as your own tables
AUTH_RATE_LIMITERratelimitThe edge guard in front of every auth route

The rate limiter is a policy rather than a resource: nothing exists behind it in your account. It is written at 100 requests per 60 seconds, per client IP, which is a flood guard rather than a product rule. Tune it in wrangler.jsonc — add never rewrites an entry you have changed — and note that Cloudflare accepts a period of 10 or 60 and nothing else.

No handler code lands anywhere. The logic stays in the package and upgrades with it on a minor release. pithy add auth --eject is the escape hatch if the day comes that you want the source, and it is a one-way door: an ejected capability is never reconciled again.

What runs

add re-reads the config after wiring and runs that Worker’s dev migrations, so the migration that just arrived is in the registry. Auth’s tables are all pithy_auth_*:

users · sessions · accounts · verifications · jwks · rate_limit · devices

The first six are Better Auth’s. devices is Pithy’s own. Your own tables sit beside them, untouched.

What is minted, and where it goes

Two dev secrets land in <config>/<project>/secrets.jsonc — in the Pithy config directory, outside every checkout:

SecretWhy
auth-session-secretBetter Auth’s signing and encryption secret
email-link-signing-keyFrom the email prerequisite, for tracking and unsubscribe links

auth-session-secret is written only when absent. A new value signs out every live session, so re-running pithy add auth never replaces one.

Nothing here is in your repository. There is nothing to gitignore, nothing git add -A can reach, nothing npm pack can carry, and nothing an rm -rf on the clone destroys.

What it does not do

add reaches no Cloudflare account. It writes config and local D1 only, so everything below is left for later and reported in the run’s notes:

  • Deployed environments need their own session secret. pithy secrets create auth-session-secret — and pithy secrets provision creates it for you, because a session signing key is arbitrary: any random string works, since nothing outside the project validates one.
  • Email needs provisioning before a magic link is delivered for real. pithy email provision. Until then, pithy dev falls back to the local simulator, says so in its ready banner, and writes the rendered message to disk — so the link is readable either way.

The three config options

OptionDefaultWhat it decides
basePath/authWhere the handler mounts. It must match the OAuth redirect URIs you register, so changing it later means changing them too
baseURLPUBLIC_ORIGINThe public origin of this Worker. Callbacks, JWKS and magic-link URLs are built from it. A dev composition ignores it and serves on whatever host the request arrived at — local dev has no TLS and its port is assigned per run, so it is the one address nobody can write down
disableSignUpfalseWhen true, sign-in never provisions a new user. An unknown email gets no email at all, which is deliberate: replying no such account is an enumeration oracle

You also set trustedOrigins by hand — every web origin and mobile deep-link scheme allowed as a redirect target and CSRF origin. Dev needs neither it nor baseURL: it resolves its own base URL and trusts its own origin.

Turning a social provider on

Each is a config flag plus one credential you take from that provider’s console. Nothing can mint them.

ProviderConfigSecretConsole
Googlegoogle: { enabled: true }auth-google-credentialsGoogle Cloud credentials
Appleapple: { enabled: true }auth-apple-credentialsApple Developer keys
GitHubgithub: { enabled: true }auth-github-credentialsGitHub developer settings
Facebookfacebook: { enabled: true }auth-facebook-credentialsFacebook apps

Each is a typed JSON secret holding that provider’s client id and client secret together, created with pithy secrets create <name>. Each rotates manually — none of the four returns a replacement over an API, so a human opens the console and records the result.

Apple’s client secret is a signed JWT that expires within six months. Rotate it before it does.

There are no CLI flags for any of this. Which providers exist is declared in config, and a flag would be a second source of truth frozen at scaffold time — the scaffolded sign-in screens read the composed config at runtime, so enabling a provider stays a one-line edit and a redeploy.

Check it worked

pithy dev

The Worker boots, and pithy doctor reports auth under the Worker’s health with its bindings satisfied and its migrations applied. If something is missing, it names the binding or the command rather than the symptom.

ESC