Skip to main content

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 component

The session cookie stays on the Nuxt origin. Better Auth stores its data in Convex.

Required files

FileResponsibility
convex/convex.config.tsRegister the Better Auth component
convex/auth.config.tsConfigure Convex JWT verification
convex/auth.tsCreate the one createBetterConvexAuth factory
convex/http.tsRegister Better Auth HTTP routes lazily
nuxt.config.tsRegister 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.

convex/auth.ts
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:

OptionDefaultAllowed range
expiresIn604800 (7 days)3600 (1 hour) to 2592000 (30 days)
updateAge86400 (1 day)300 (5 minutes) to expiresIn
cookieCacheBetter Auth offenabled, 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.

convex/auth.ts
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: '...' }
subCodeCheck
AUTH_CONFIG_SITE_URL_INVALIDSITE_URL is an exact origin
AUTH_CONFIG_CONVEX_SITE_URL_INVALIDThe Convex CONVEX_SITE_URL built-in
AUTH_CONFIG_SECRETS_INVALIDBETTER_AUTH_SECRETS versions and lengths
AUTH_CONFIG_OPTIONS_INVALIDFactory options, trusted providers, and library settings
AUTH_CONFIG_OAUTH_PROFILE_FAILEDThe oauthProvider profile or profile factory
AUTH_CONFIG_CONSTRUCTION_FAILEDBetter Auth construction and plugin initialization
AUTH_CONFIG_ROUTE_SITE_URL_INVALIDSITE_URL when an auth HTTP route starts
AUTH_CONFIG_ROUTE_CONSTRUCTION_FAILEDAuth 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:

VariableSet byPurpose
SITE_URLYouExact Nuxt origin used by Better Auth and trusted origins
CONVEX_SITE_URLConvex built-inExact Convex HTTP Actions origin used as session issuer
BETTER_AUTH_SECRETSYouVersioned Better Auth secrets, newest version first
BCN_AUTH_PROXY_IP_SECRETYouPrivate 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:

VariablePurpose
SITE_URLExact public Nuxt origin used by the proxy
NUXT_PUBLIC_CONVEX_URLConvex deployment URL ending in .convex.cloud
NUXT_PUBLIC_CONVEX_SITE_URLConvex HTTP Actions URL; required for local/custom domains
BCN_AUTH_PROXY_IP_SECRETSame private proxy secret configured in Convex
BCN_AUTH_TRUSTED_CLIENT_IP_HEADERHeader 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:

ts
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_SECRETS versions only as long as retained ciphertext needs them; test decryption before removing one.
  • Provision a separate BCN_AUTH_PROXY_IP_SECRET in both Nuxt and Convex.
  • Set trustedClientIpHeader outside 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:ensureSigningKey once 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.