Security model
What au1h protects, how, and what you still need to do.
Trust boundaries
- The application slug is public. It selects the tenant, like an OAuth
client_id. Knowing a slug lets you sign up or sign in as a user of that app, never as another app's user and never as an admin. - The application secret is private. It authenticates your backend to the server-to-server API. It is stored as a scrypt hash and shown once.
- The JWT audience is the slug. Your backend accepts only tokens minted for it.
- The admin portal is a tenant too, reserved as
au1h-admin. Admin sign-up is open only for the very first admin, for emails inAU1H_ADMIN_EMAILS, or for pending workspace invitations. Nothing is inferred fromOriginorReferer. - The admin API (
/api/v1/*) requires an admin session and is scoped to the caller's workspace: applications, users and routes of other workspaces are invisible.
Isolation
Tenant scoping lives in the database adapter, below Better Auth, and in the Redis session cache. Every read, write, update and delete on users, sessions, accounts, organizations, members, invitations and verification tokens is confined to the request's application. Unit tests cover cross-tenant reads, writes by known id, the admin scope, transactions and concurrent interleaved requests; integration tests against Postgres cover the email flows and CORS.
Browser origins are scoped the same way. An application's allowed origins grant
credentialed cross-site access to that application only, Better Auth's CSRF and
callback-URL checks trust only the requesting application's origins, and the admin API
trusts only CORS_ORIGIN. Registering an origin in your workspace cannot be used to drive
another admin's browser against their workspace. Origins are validated as bare
scheme://host[:port]; no paths or wildcards.
Emailed links (verification, reset, magic link) carry the application and their tokens are bound to it; see Email.
Credentials and secrets
- Passwords: Better Auth's scrypt hashing.
- Application secrets: scrypt-hashed at rest; the plaintext is never retrievable.
- JWT signing key: Ed25519, generated on first use, private key encrypted with
BETTER_AUTH_SECRET. - Logs redact tokens, passwords, secrets and cookie headers.
Rate limits
Redis-backed, shared across instances: /api/v1/*, /proxy/* and /api/apps/* by client
IP (plus app slug where relevant). Better Auth's own limiter protects /api/auth/* in
production. The client IP is the socket address, or with AU1H_TRUST_PROXY=true the last
hop of x-forwarded-for (the one your proxy appended); clients cannot choose their own
bucket, and the same IP is forwarded to backends behind the gateway.
Proxy targets
Route backends must be http(s) without embedded credentials. In production, loopback,
private and link-local addresses (including IPv4-mapped IPv6 forms) are refused so au1h
cannot be used to reach internal services, and backend redirects are relayed to the client
rather than followed. The check is on the literal hostname; DNS rebinding is not detected.
The gateway's x-au1h-token carries typ: "au1h-proxy". The SDKs accept it only as gateway
proof, compare it against x-user-id and x-user-email, and reject it as a Bearer token,
so a user's own access token cannot forge gateway headers.
What you must still do
- Run au1h behind TLS and set
NODE_ENV=production. - Keep backends behind the gateway on a private network, or verify
x-au1h-token. - Treat
AU1H_ADMIN_EMAILSas the list of people who may administer every application. - Report vulnerabilities privately; see
SECURITY.mdin the repository.