Docs / Authentication & users reference
Guidedeploymill://guides/auth

Authentication & users reference (deploymill)

You are an agent about to add user accounts / login to a deploymill-managed app (not to deploymill itself — deploymill's own sign-in is a separate, server-level concern). This guide is the paved road for doing that on this platform: which auth approach fits, and — more importantly — the four platform facts that make auth on deploymill different from auth on a laptop. Get those four right and almost any auth library works; get them wrong and login "works locally" but breaks the moment it's deployed or previewed.

Read it alongside:

  • deploymill://guides/secrets — secret values (auth signing key, OAuth client secret) never cross the agent; they go through the vault.
  • deploymill://guides/env-vars — host-pinned URLs (the auth base URL) are non-secret config set per-environment.
  • deploymill://guides/previews — every preview is a distinct origin, so auth needs per-preview wiring.
  • deploymill://guides/database/{node|python} — the auth tables live in the managed Postgres you provision; migrations run on container start.

The four platform facts (get these right)

Everything else is detail. These are the constraints deploymill imposes that change how auth is wired:

  1. Sessions must live in Postgres, not on the container filesystem or in memory. The container filesystem is ephemeral (wiped every deploy) and there is no shared in-process memory across restarts. A file-backed or memory-backed session store loses every session on each deploy and silently logs everyone out. Use a SQL-backed session/user store on the managed Neon Postgres you provision with database: { provider: "neon" }. This is the single biggest reason to provision a database before wiring auth.
  1. The auth base URL is host-pinned and differs per environment. Auth libraries bake an absolute base URL into cookies, OAuth redirect URIs, and email links (BETTER_AUTH_URL, NEXTAUTH_URL, AUTH_URL, PUBLIC_URL, …). It is not a secret — set it with set_env_vars to the app's real prod host (e.g. https://my-app-acme.detz.dev), and on each preview pass it via create_preview's envOverrides using the literal token ${PREVIEW_URL} (substituted with the preview's full URL at creation). If this is wrong, cookies are scoped to the wrong domain and OAuth round-trips fail with redirect-mismatch.
  1. The signing secret and any OAuth client secret go through the vault — never set_env_vars, never the chat. The session signing key (BETTER_AUTH_SECRET, AUTH_SECRET, NEXTAUTH_SECRET, …) and OAuth client secrets are secrets. Mint a strong random value or have the user supply it via request_secret({ name }) → hand them the link → poll secret_request_statusbind_secret({ applicationId, name }) (or declare under secrets in .deploymill/project.json and reconcile). The value is resolved server-side; it never appears in a transcript. See deploymill://guides/secrets.
  1. Secure cookies need HTTPS, and the default sslip.io domain is HTTP-only. When the server runs without WILDCARD_DOMAIN_BASE, attach_domain mints an sslip.io host served over plain HTTP. Cookies marked Secure (and most auth libraries default to Secure in production) will be dropped by the browser over HTTP — login appears to succeed then immediately "forgets" you. Either run the app on a real HTTPS domain (server configured with WILDCARD_DOMAIN_BASE, giving https://<app>-<org>.<base> with Let's Encrypt), or, only for a throwaway HTTP host, relax the cookie's Secure/sameSite settings knowingly. Prefer HTTPS; don't ship a real login flow on an HTTP host.

Node — Better Auth (paved road)

For Node apps, Better Auth is the default recommendation, for platform reasons, not taste:

  • It's SQL-backed, so it drops straight onto the managed Neon Postgres (database: { provider: "neon" }) — satisfies fact #1 with no extra infra.
  • Its config takes an explicit baseURL / reads BETTER_AUTH_URL — the host-pinned-URL knob fact #2 needs.
  • It's exactly what deploymill itself runs on, so the platform is known to support its session-cookie and OAuth-callback shapes.

Wiring sketch (read deploymill://guides/conventions/node for the surrounding app conventions):

  1. Provision the database first. Add database: { provider: "neon" } to .deploymill/project.json, reconcile_project, follow the returned deploymill://guides/database/node to add pg + a pooled module + migrate-on-start.
  2. Add Better Auth, pointed at the same pool. Generate its tables as a migration so they're created by the same migrate-on-start step (don't rely on a separate manual schema push — the container start is the only reliable migration point).
  3. Signing secret via the vault: request_secret({ name: "BETTER_AUTH_SECRET" }), have the user fill it, then bind_secret({ applicationId, name: "BETTER_AUTH_SECRET" }) — or declare it under secrets in project.json.
  4. Base URL via env: set_env_vars({ applicationId, vars: { BETTER_AUTH_URL: "https://<app>-<org>.<base>" } }). (The prod host is whatever list_domains reports.)
  5. deploy.

OAuth providers (Google, GitHub, …): the client id can go in set_env_vars (it's not secret), the client secret goes through the vault. Register the provider's redirect URI as <BETTER_AUTH_URL>/api/auth/callback/<provider>. The end-to-end Google example is in deploymill://guides/secrets ("Wiring Google OAuth into an app").

Python — a SQL-backed library over the managed Postgres

Python has no single deploymill default, but the same four facts decide it. Pick a library that stores sessions/users in Postgres (e.g. FastAPI with Authlib for OAuth + a sessions table on the managed DB, or fastapi-users with its SQLAlchemy backend). Avoid signed-cookie-only or in-memory session stores for anything beyond a demo — they fall foul of fact #1 the moment you scale or redeploy. Provision the database first (deploymill://guides/database/python), keep the auth tables in your Alembic migrations so they run on container start, set the host-pinned base URL via set_env_vars, and push the signing/OAuth secrets through the vault.

Hosted IdP (Clerk, Auth0, WorkOS, …)

Fine to use if the user wants it — it sidesteps fact #1 (the IdP holds the sessions). Facts #2–#4 still apply: the IdP's allowed callback/redirect URLs must include the prod host and every preview host, the IdP API secret goes through the vault, and the publishable/client key (non-secret) can go in set_env_vars. Per-preview callback URLs are the usual friction point — see below.

Previews and auth

Each preview is a separate origin with its own hostname (and, by default, its own forked Neon database branch — so preview users are isolated from prod users). Two things break if you forget them:

  • Base URL. Pass the auth base URL in create_preview's envOverrides with the ${PREVIEW_URL} token, e.g. envOverrides: { BETTER_AUTH_URL: "${PREVIEW_URL}" }. Without this the preview inherits the parent's prod URL and every cookie/redirect targets the wrong host.
  • OAuth redirect URIs. A provider only redirects back to URIs you pre-registered. Preview hostnames are deterministic but per-branch, so social login on a brand-new preview host fails until that callback URL is registered with the provider. For provider-backed login on previews, prefer either (a) email/password or magic-link auth (no external redirect allow-list), or (b) registering a wildcard/extra redirect URI with the provider if it supports one. This is the most common "preview deploy is up but login fails" cause — see deploymill://guides/previews.

Because previews get their own DB branch by default, a user you create on a preview does not exist on prod (and vice versa). If you set previews: { shareDatabase: true }, previews share the prod user table — convenient for testing against real accounts, but preview code then runs against prod auth data; treat it like prod.

Checklist

  • [ ] Database provisioned (database: { provider: "neon" }) and sessions/users stored there — not on disk, not in memory.
  • [ ] Auth tables created via the migrate-on-start migration, not a manual one-off.
  • [ ] Signing secret + OAuth client secret(s) in the vault (request_secretbind_secret / secrets array). Never in set_env_vars, never in chat.
  • [ ] Host-pinned base URL set via set_env_vars for prod, and via ${PREVIEW_URL} in create_preview envOverrides for previews.
  • [ ] App served over HTTPS (real domain), or Secure cookies knowingly relaxed for a throwaway HTTP host.
  • [ ] OAuth redirect URIs registered for the prod host (and previews, if social login is needed there).
  • [ ] deploy after any env/secret change — they don't take effect until the next deploy.

What NOT to do

  • Don't store sessions on the container filesystem or in process memory. Ephemeral + no shared state = everyone logged out on every deploy.
  • Don't put the signing secret or an OAuth client secret in set_env_vars. Use the vault.
  • Don't hardcode the base URL to one environment's host — it differs between prod and each preview. Drive it from env.
  • Don't ship Secure-cookie login on an HTTP sslip.io host and wonder why sessions vanish — use HTTPS.
  • Don't expect an env or secret change to apply without a deploy.