BETTER_AUTH_SECRET: what it is, how to generate it, how to rotate it
BETTER_AUTH_SECRET signs sessions and encrypts 2FA secrets. Generate it with openssl, set it per environment, and rotate it with BETTER_AUTH_SECRETS.
BETTER_AUTH_SECRET is the one secret Better Auth
uses to sign cookies and to encrypt sensitive values it stores, such as two-factor
secrets. Generate it with openssl rand -base64 32, give each environment its own
value, keep it out of git, and rotate it with the versioned BETTER_AUTH_SECRETS
variable instead of swapping it in place. This guide is checked against
[email protected], the version useDeploy ships, and its source.
Generate it
The options reference gives the command:
openssl rand -base64 32That is 32 random bytes, 44 characters. Put it in your server env file:
BETTER_AUTH_SECRET=<paste the output>Better Auth looks for BETTER_AUTH_SECRET first and AUTH_SECRET second, or you
can pass secret to betterAuth({ ... }). Three rules are enforced or warned
about in the 1.6.9 source (dist/context/create-context.mjs):
- No secret set: it falls back to a published placeholder. In production this
throws (
You are using the default secret). Outside production it keeps running, so a forgotten variable can hide in development. - Shorter than 32 characters: a warning is logged.
- Low entropy: a warning is logged when the estimated entropy is under 120
bits.
password-password-password-12345is 32 characters long and still warns, because few distinct characters carry little entropy.
What it protects
In 1.6.9 the secret is used for two different jobs:
- Signing. The session cookie is an HMAC-signed value
(
ctx.getSignedCookie(..., ctx.context.secret)indist/api/routes/session.mjs). So are the OAuth state cookie, the two-factor challenge cookie and the trusted-device cookie. A cookie signed with another secret is rejected. - Encrypting data at rest. Two-factor TOTP secrets and backup codes are
encrypted with it (
plugins/two-factor/totp/index.mjs), and so are the payloads of some other plugins.
The session itself is a row in your database. The cookie only carries the session token plus a signature. If you leak the secret an attacker can forge a valid cookie for a token they know, and can decrypt any encrypted value they can read from your database.
Set it in every environment
- One value per environment. Development, preview and production get different secrets. A preview deploy that shares the production secret can decrypt production data if it ever points at the same database.
- The same value on every replica. If two instances of your API have different secrets, a session cookie signed by one fails on the other and users get logged out at random.
- Never commit it. Commit a placeholder in
.env.exampleand keep the real value in your host's secret store.
How useDeploy handles it
useDeploy validates the variable at boot in apps/server/src/bootstrap/env.ts:
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.string().url(),The server refuses to start with a short value, instead of logging a warning you
may never read. The adapter in
apps/server/src/modules/iam/infrastructure/auth/better-auth.adapter.ts calls
betterAuth({ ... }) without a secret option, so Better Auth reads the
variable from the environment itself. .env.example ships the placeholder
replace-me-with-32-plus-random-chars and the README shows the openssl
command above.
One detail that matters for rotation: useDeploy reads BETTER_AUTH_SECRET a
second time, for its own purposes. The platform-admin impersonation cookie is
HMAC-signed with env.BETTER_AUTH_SECRET (modules/platform/interfaces/http).
Better Auth's versioned secrets do not cover that code path.
Rotate it without losing data
Replacing the value in place breaks things, because everything encrypted with the
old secret becomes unreadable. Better Auth has a versioned mechanism for this.
Per the options reference,
set BETTER_AUTH_SECRETS to comma-separated version:secret pairs, current one
first:
BETTER_AUTH_SECRETS=2:<new secret>,1:<old secret>The first entry encrypts everything new; the rest can only decrypt. Each
encrypted value carries its key version, so values written under version 1 stay
readable while version 1 is in the list. The equivalent in code is the secrets
option, an array of { version, value } objects.
We checked what that means in practice against 1.6.9 by encrypting a TOTP-style value with version 1 and decrypting it with different key sets:
import { symmetricEncrypt, symmetricDecrypt } from 'better-auth/crypto';
const ct = await symmetricEncrypt({ key: v1, data: 'JBSWY3DPEHPK3PXP' });
await symmetricDecrypt({ key: bothVersions, data: ct }); // 'JBSWY3DPEHPK3PXP'
await symmetricDecrypt({ key: onlyVersion2, data: ct });
// Error: Secret version 1 not found in keys (key may have been retired)So the rotation procedure is:
- Generate a new secret.
- Set
BETTER_AUTH_SECRETS=2:<new>,1:<old>and keepBETTER_AUTH_SECRETset to the old value. It is the fallback for data written before versioning. - Deploy. New writes use version 2; old encrypted values still decrypt.
- Do not drop version 1 yet. Per the docs, data is re-encrypted when it is next written. A TOTP secret is only written again when its owner re-enrolls, so version 1 has to stay in the list until you are sure nothing depends on it, or until you force those users to set up 2FA again.
What rotation does to sessions
The docs say rotation is non-destructive for encrypted data. They do not say what
happens to signed cookies. In the 1.6.9 source, signed cookies are signed and
verified with ctx.context.secret, which is the first entry of the list, and
there is no fallback to older versions. Expect every existing session cookie to
stop validating after the cutover, so everyone signs in again. That is a
reading of the source, not a documented guarantee, so test it on a staging copy
before you rotate production at peak hours.
If the old secret leaked, keeping it in the list for decryption keeps the encrypted values exposed. Rotate, wait for users to sign in again, then remove it and plan for the 2FA re-enrollment that follows.
Common errors
| Symptom | Cause |
|---|---|
useDeploy fails at boot on BETTER_AUTH_SECRET | Not set, or shorter than 32 characters (the Zod check). |
You are using the default secret | Production with no secret configured. |
Warning: appears low-entropy | The value was typed by hand. Regenerate it. |
| Users logged out after a deploy | The secret changed, or replicas disagree. |
Secret version N not found in keys | You dropped a version that still protects stored data. |
Frequently asked questions
Is BETTER_AUTH_SECRET the same as BETTER_AUTH_URL?
No. The URL tells Better Auth its public base address. The secret is a
credential. Only the server needs the secret; the browser never sees it.
Is it a JWT secret? Not for the default session setup. Sessions are database rows and the cookie is an HMAC-signed session token. Plugins such as the JWT plugin use it for their own purposes.
Can I use AUTH_SECRET instead?
Yes, Better Auth falls back to it when BETTER_AUTH_SECRET is unset. Pick one
name and stick with it.
Can I rotate with zero logouts? Not with the versions we read. Encrypted data survives rotation, signed session cookies do not.
useDeploy ships Better Auth wired up with sessions, 2FA, magic links, and the env validation above. See the Better Auth integration and the auth docs.