Production deploy
Everything on this page is infrastructure rather than API surface: the five things that are fine on localhost and wrong in production.
Secrets
auth = BetterAuth(secret=os.environ["BETTER_AUTH_SECRET"])openssl rand -base64 32At least 32 characters, or construction fails:
ValueError: secret must be at least 32 characters. Generate one with `openssl rand -base64 32`The secret signs session cookies, OAuth state and every derived key. Changing it signs everyone out, which is why rotation is versioned rather than a swap:
auth = BetterAuth(
secret=os.environ["BETTER_AUTH_SECRET"], # the current one
secrets=[
(2, os.environ["BETTER_AUTH_SECRET"]),
(1, os.environ["BETTER_AUTH_SECRET_V1"]), # keep until old values expire
],
)New values are written under the highest version; old ones keep verifying until you drop the pair. Retire a version once nothing signed with it can still be live: one session.expires_in window is the safe floor.
A real adapter
MemoryAdapter is the default so a quickstart runs with no setup. It is per-process and it forgets everything on restart.
from sqlalchemy.ext.asyncio import create_async_engine
from better_auth.adapters.sqlalchemy import SQLAlchemyAdapter
engine = create_async_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)
auth = BetterAuth(secret=..., adapter=SQLAlchemyAdapter(engine))Use real migrations. await adapter.create_tables() is a development convenience. In production, let Alembic own the schema so plugin tables and additional_fields are versioned with your code.
The adapter checks the schema on first use. If a table or column Better Auth writes is missing, it logs a Database schema mismatch report and every auth request fails until the migration is applied. Run your migrations before the new code takes traffic. See Schema check.
base_url and HTTPS
auth = BetterAuth(
secret=...,
base_url="https://example.com", # not localhost, not http
trusted_origins=["https://app.example.com"], # a separate frontend origin
)An https base_url is what turns on Secure cookies and the __Secure- name prefix. It is also the origin that CSRF checks and every callbackURL and redirectTo are validated against, which is what makes open redirects impossible. A frontend on another origin must be listed in trusted_origins or its requests will be rejected.
For one process behind several hostnames:
from better_auth import DynamicBaseURL
auth = BetterAuth(
secret=...,
base_url=DynamicBaseURL(
allowed_hosts=["example.com", "*.vercel.app"], protocol="https"
),
)The base URL is derived per request from the Host header and restricted to allowed_hosts; each pattern also becomes a trusted origin. This is the option for preview deployments: for social sign-in from preview URLs specifically, see the OAuth Proxy plugin.
Trust your proxy, not the client
Behind a load balancer, the socket address is the proxy's. The client IP comes from a header, and a header can be forged: an attacker who controls x-forwarded-for defeats per-IP rate limiting entirely.
from better_auth import IPAddressOptions
auth = BetterAuth(
secret=...,
ip_address=IPAddressOptions(
ip_address_headers=["cf-connecting-ip", "x-forwarded-for"],
trusted_proxies=["10.0.0.0/8"], # only these may set the header
),
)List the CIDR ranges of your own proxies in trusted_proxies. The chain is walked from the right, and the first address outside the trusted set is the client. Set disable_ip_tracking=True if you would rather not store IPs at all.
Forwarded host and protocol
X-Forwarded-Host and X-Forwarded-Proto are ignored by default (trusted_proxy_headers=False, as in better-auth 1.7). The host then comes from the Host header and the protocol from the request URL. That is enough when:
base_urlis a fixed string, or aDynamicBaseURLwithprotocol="https";- the proxy passes the original
Hostheader through (nginx:proxy_set_header Host $host;); - uvicorn runs with
--proxy-headers, so the request URL already sayshttps(see Running it).
Turn it on only when the proxy replaces Host with an internal name and sends the public host in X-Forwarded-Host instead:
auth = BetterAuth(
secret=...,
base_url=DynamicBaseURL(allowed_hosts=["example.com"], protocol="https"),
trusted_proxy_headers=True,
)Then make sure the proxy overwrites both headers on every request, so a client cannot choose them. With nginx:
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;A proxy that appends to a client's header, or passes it through unchanged, lets that client pick the host Better Auth builds URLs and origin checks from. Keep the app port closed to everything except the proxy.
Rate limiting across workers
from better_auth import RateLimit
auth = BetterAuth(
secret=...,
rate_limit=RateLimit(
enabled=True,
window=10,
max=100,
storage="database", # or "secondary-storage"
custom_rules={"/sign-in/email": {"window": 10, "max": 3}},
),
)Better Auth's per-path rules ship built in. The trap is the default: storage="memory" counts per process, so four uvicorn workers means four times the limit. Use "database" for the shared adapter, or "secondary-storage" with a Redis-shaped store:
auth = BetterAuth(
secret=...,
secondary_storage=my_redis,
rate_limit=RateLimit(enabled=True, storage="secondary-storage"),
)Any object implementing the SecondaryStorage protocol works. MemorySecondaryStorage ships for tests.
Running it
uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4 --proxy-headers--proxy-headers is what makes uvicorn read X-Forwarded-Proto, so the application sees https and cookies come out Secure. Pair it with --forwarded-allow-ips set to your proxy's addresses.
With more than one worker, in-process state is per worker: use a shared rate-limit store (above), and a shared secondary_storage if you use one at all.
Vercel
The Python function runtime serves an ASGI app directly, so BetterAuthFastAPI needs nothing special. Set BETTER_AUTH_SECRET, BETTER_AUTH_URL and DATABASE_URL as environment variables, and use a connection pooler (Neon, Supabase pooler, PgBouncer). Serverless invocations open connections faster than a database wants.
Preview deployments get a different hostname on every push, which breaks the one redirect URI registered with each OAuth provider. Two fixes: DynamicBaseURL with allowed_hosts=["*.vercel.app"], or the OAuth Proxy plugin to bounce callbacks through production.
What is already hardened
You do not have to configure these; they are the defaults.
- CSRF. Non-GET requests are origin-checked against
base_urlandtrusted_origins. - Open redirects. Every
callbackURLandredirectTois validated against trusted origins. - User enumeration. Sign-in runs a dummy scrypt when the user does not exist, so an unknown address and a wrong password take the same time and return the same 401.
- Password storage. scrypt at
N=16384, r=16, p=1, dkLen=64. - Secrets at rest.
AccountOptions(encrypt_oauth_tokens=True)encrypts stored provider tokens with XChaCha20-Poly1305. - Constant-time comparison on the cookie-cache signature.
Two options exist to turn parts of this off: disable_csrf_check and disable_origin_check. Both are for tests. If a production request is being rejected, the answer is an entry in trusted_origins.
A production checklist
- [ ]
secretfrom the environment, ≥32 characters, never in the repository - [ ] A real adapter, with schema managed by migrations
- [ ]
base_urlonhttps, real frontend origins intrusted_origins - [ ]
trusted_proxiesset if you are behind a load balancer - [ ]
trusted_proxy_headers=Trueonly if the proxy overwritesX-Forwarded-HostandX-Forwarded-Proto - [ ] Migrations applied before deploy (the schema check fails requests otherwise)
- [ ]
rate_limit.storagenot"memory"when running more than one worker - [ ]
--proxy-headers(and--forwarded-allow-ips) on uvicorn - [ ] Mailer callbacks wired:
send_reset_password,send_verification_email - [ ]
encrypt_oauth_tokens=Trueif you store provider tokens - [ ]
CookieCachemax_agesmall enough that revocation is timely