Better Auth setup
Configure Better Auth in Convex and connect it to Nuxt through the same-origin auth proxy.
Every auth request takes this path:
Browser or Nuxt SSR
→ same-origin /api/auth/*
→ Better Convex Nuxt Nitro proxy
→ Convex HTTP Actions site
→ Better Auth Convex componentThe session cookie stays on the Nuxt origin. Better Auth stores its data in Convex.
Required files
| File | Responsibility |
|---|---|
convex/convex.config.ts | Register the Better Auth component |
convex/auth.config.ts | Configure Convex JWT verification |
convex/auth.ts | Create the one createBetterConvexAuth factory |
convex/http.ts | Register Better Auth HTTP routes lazily |
nuxt.config.ts | Register the Nuxt module and public Convex URLs |
The complete minimal versions are in Add authentication. Start from those files. Add your own settings as options of createBetterConvexAuth.
Approve new users
Use beforeUserCreate when the application must approve each new Better Auth
user against its own Convex data. The callback can deny the new user. It can
also replace the new user's id and normalized email, and nothing else.
export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
beforeUserCreate: async ({ ctx, user }) => {
const invite = await ctx.runQuery(internal.members.findInvite, {
email: user.email.trim().toLowerCase(),
})
if (!invite) return { allowed: false }
return {
allowed: true,
user: {
email: invite.email,
...(invite.existingUserId ? { id: invite.existingUserId } : {}),
},
}
},
})internal.members.findInvite is your own internal query. The callback
receives a read-only copy of the new user. A denial, a thrown error, or an
invalid id or email rejects the sign-up with AUTH_USER_CREATE_REJECTED.
Better Convex does not expose raw Better Auth database hooks. Keep roles,
memberships, and access checks in your Convex functions.
Set the session policy
session sets the Better Auth session lifetime in whole seconds. The factory
checks the values when it creates Better Auth:
| Option | Default | Allowed range |
|---|---|---|
expiresIn | 604800 (7 days) | 3600 (1 hour) to 2592000 (30 days) |
updateAge | 86400 (1 day) | 300 (5 minutes) to expiresIn |
cookieCache | Better Auth off | enabled, strategy, and maxAge of 1 to 300 seconds |
When expiresIn is shorter than one day, the default updateAge is equal to
expiresIn.
cookieCache lets Better Auth endpoints trust a signed session cookie instead
of the database for up to maxAge seconds (Better Auth's default is 300). A
revoked session can pass those endpoints until the cached cookie expires, so
the cache is capped at five minutes and stateless refreshCache is rejected.
Convex session tokens always re-read the database.
export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
session: { expiresIn: 24 * 60 * 60, updateAge: 60 * 60 },
})The Convex session token is separate. It always expires after 15 minutes, and the module refreshes it while the session is live.
Add social providers
Pass GitHub, Google, or another Better Auth social provider in
socialProviders. Account linking has fixed rules. See
Social sign-in for the complete
setup.
Unsupported options fail at startup
createBetterConvexAuth rejects an unknown option key when the module loads.
This includes unknown keys in session, account, and
account.accountLinking. The factory sets plugins, database,
databaseHooks, user, advanced, rateLimit, baseURL, and basePath.
Diagnose configuration errors
A configuration failure at request time stays opaque. The thrown error is
AUTH_CONFIG_INVALID, and an auth HTTP response has the body
{ "code": "AUTH_CONFIG_INVALID" }. The Convex deployment log gets one line
with the failed stage:
[better-convex] AUTH_CONFIG_INVALID { subCode: 'AUTH_CONFIG_SECRETS_INVALID', cause: '...' }subCode | Check |
|---|---|
AUTH_CONFIG_SITE_URL_INVALID | SITE_URL is an exact origin |
AUTH_CONFIG_CONVEX_SITE_URL_INVALID | The Convex CONVEX_SITE_URL built-in |
AUTH_CONFIG_SECRETS_INVALID | BETTER_AUTH_SECRETS versions and lengths |
AUTH_CONFIG_OPTIONS_INVALID | Factory options, trusted providers, and library settings |
AUTH_CONFIG_OAUTH_PROFILE_FAILED | The oauthProvider profile or profile factory |
AUTH_CONFIG_CONSTRUCTION_FAILED | Better Auth construction and plugin initialization |
AUTH_CONFIG_ROUTE_SITE_URL_INVALID | SITE_URL when an auth HTTP route starts |
AUTH_CONFIG_ROUTE_CONSTRUCTION_FAILED | Auth construction inside an auth HTTP route |
An unexpected Better Auth handler error logs AUTH_HANDLER_FAILED with
subCode: 'AUTH_HANDLER_THREW'. The logged cause is shortened to 200
characters. Token-like strings, URL credentials, and secret, token, cookie, and
password values are removed from it.
Environment variables
These values exist in the Convex environment. Set the values marked "You". Convex sets the built-in value itself:
| Variable | Set by | Purpose |
|---|---|---|
SITE_URL | You | Exact Nuxt origin used by Better Auth and trusted origins |
CONVEX_SITE_URL | Convex built-in | Exact Convex HTTP Actions origin used as session issuer |
BETTER_AUTH_SECRETS | You | Versioned Better Auth secrets, newest version first |
BCN_AUTH_PROXY_IP_SECRET | You | Private Nuxt-to-Convex client-IP signing secret |
In development, better-convex init sets SITE_URL, BETTER_AUTH_SECRETS,
and BCN_AUTH_PROXY_IP_SECRET for you. It also writes the proxy secret to
.env.local, so Nuxt uses the same value.
Do not try to override CONVEX_SITE_URL. Configure Nuxt's matching public
HTTP Actions origin through NUXT_PUBLIC_CONVEX_SITE_URL.
Set these for the Nuxt build/runtime:
| Variable | Purpose |
|---|---|
SITE_URL | Exact public Nuxt origin used by the proxy |
NUXT_PUBLIC_CONVEX_URL | Convex deployment URL ending in .convex.cloud |
NUXT_PUBLIC_CONVEX_SITE_URL | Convex HTTP Actions URL; required for local/custom domains |
BCN_AUTH_PROXY_IP_SECRET | Same private proxy secret configured in Convex |
BCN_AUTH_TRUSTED_CLIENT_IP_HEADER | Header your host sets to one client IP; required outside loopback |
SITE_URL must be an exact origin. Use HTTPS outside loopback development. Do not include a path, query, or fragment.
Proxy behavior
The module serves the /api/auth/* route. It sends each request once to the
Convex site origin and does not follow redirects on the server. The browser
receives OAuth and sign-in redirects. Auth needs a host that runs the Nitro
server; a static host cannot run the proxy.
To let AI agents act for a user, add the delegated OAuth and MCP profile. It uses the same Better Auth component. It does not add a second auth store.
Warning: Outside exact loopback development, set
auth.trustedClientIpHeader to a header that your host or load balancer always
overwrites with one client IP. Never pick a header that a client can send
unchanged. If public traffic can reach the Nuxt server without passing through
that host, a client can set the header itself.
The proxy removes the caller's forwarding headers. It signs only the address
from trustedClientIpHeader. A missing, invalid, or multi-value header stops
the request before Better Auth runs. The proxy, the SSR token exchange, and
serverConvex all send the same signed address. There is no public helper
that exchanges a cookie for a token outside this path.
For Vercel, see Deploy to Vercel.
Convex token rate limit
The token route GET /api/auth/convex/token allows 300 requests per 10
seconds for each client IP. The limit is per IP address, not per user or
session. Better Auth stores the counter in the component database, so every
server instance shares it. A blocked request returns 429 with an
X-Retry-After value in seconds.
This limit is higher than the sign-in limits because SSR, hydration, and query
refreshes all request tokens. You cannot change it. Repeated 429 responses
usually mean that trustedClientIpHeader reports the same IP for many users,
for example the address of a load balancer.
The auth proxy limits request and response bodies to 1 MiB. You cannot change
these limits. trustedClientIpHeader is the only proxy option:
convex: {
auth: {
origin: process.env.SITE_URL ?? 'http://localhost:3000',
trustedClientIpHeader: process.env.BCN_AUTH_TRUSTED_CLIENT_IP_HEADER,
}
}Production checklist
- Use an exact HTTPS
SITE_URL. - Generate a unique production secret.
- Keep old
BETTER_AUTH_SECRETSversions only as long as retained ciphertext needs them; test decryption before removing one. - Provision a separate
BCN_AUTH_PROXY_IP_SECRETin both Nuxt and Convex. - Set
trustedClientIpHeaderoutside exact loopback development to a header that your host overwrites. - Make sure public traffic can reach the Nuxt server only through that host.
- Register social provider callback URLs on the public application origin.
- Point Nuxt at the exact Convex HTTP Actions URL of the selected deployment.
- Run
auth:ensureSigningKeyonce before the first user signs in. - Keep Nuxt auth routes uncached.
- Test sign-up, sign-in, reload, refresh, and sign-out on the deployed application.
- Verify protected Convex functions reject anonymous calls directly.