Authentication

Learn how authentication works with better-auth in this boilerplate

This boilerplate uses better-auth for a production-ready, passwordless authentication system: email OTP sign up and sign in, optional sign in with X, bearer sessions for mobile clients, email change, and session management.

Overview

The authentication system provides:

  • Passwordless email OTP for sign up and sign in (first successful OTP creates the account)
  • Sign in with X (OAuth 2.0), enabled only when X credentials are configured
  • Bearer sessions for Capacitor / Ionic clients (Authorization: Bearer …)
  • Cookie sessions for the Nuxt web app
  • Email change with verification (link-based for web settings; OTP endpoints for mobile)
  • Session management with cookie caching for performance
  • Type-safe client with auto-imported composables

Password sign-in, sign-up, and reset flows are disabled.

Environment variables

Before using authentication, configure these environment variables in your .env file:

# Authentication (better-auth)
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=your-secret-key-here
BETTER_AUTH_URL=http://localhost:3000
# Comma-separated extra trusted origins for CORS / better-auth
# Capacitor origins (capacitor://localhost, ionic://localhost, http://localhost) are always included
BETTER_AUTH_TRUSTED_ORIGINS=
# Comma-separated IPs/CIDRs of proxies in front of the app (only needed behind multiple hops)
BETTER_AUTH_TRUSTED_PROXIES=

# Sign in with X (optional)
TWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=
  • BETTER_AUTH_SECRET - A random secret key used for signing tokens and cookies. Generate a secure value using openssl rand -base64 32.
  • BETTER_AUTH_URL - The base URL of your application. Use http://localhost:3000 for local development and your production domain when deployed.
  • BETTER_AUTH_TRUSTED_ORIGINS - Optional comma-separated list of additional trusted origins (used by better-auth and the API CORS middleware).
  • BETTER_AUTH_TRUSTED_PROXIES - Optional comma-separated IPs/CIDRs of proxies in front of the app. See rate limiting.
  • TWITTER_CLIENT_ID / TWITTER_CLIENT_SECRET - Optional OAuth 2.0 credentials for sign in with X. Both or neither; email OTP is unaffected when they are absent.
Never commit your actual BETTER_AUTH_SECRET to version control. Keep it secure and regenerate it if exposed.

Authentication configuration

The auth configuration is located in server/utils/auth.ts:

server/utils/auth.ts
import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import { bearer, emailOTP } from 'better-auth/plugins'
import { prisma } from '@@/lib/prisma'

export const auth = betterAuth({
  emailAndPassword: { enabled: false },
  session: {
    expiresIn: 60 * 60 * 24 * 60, // 60 days
    updateAge: 60 * 60 * 24, // 1 day
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // 5 minutes
    },
  },
  rateLimit: {
    enabled: true,
    window: 60,
    max: 100,
    customRules: {
      '/email-otp/send-verification-otp': { window: 60, max: 20 },
      '/sign-in/email-otp': { window: 60, max: 30 },
    },
  },
  advanced: {
    ipAddress: {
      ipAddressHeaders: ['x-forwarded-for', 'x-real-ip'],
      trustedProxies, // from BETTER_AUTH_TRUSTED_PROXIES
    },
  },
  database: prismaAdapter(prisma, {
    provider: 'postgresql',
  }),
  plugins: [
    bearer(),
    emailOTP({
      otpLength: OTP_LENGTH,
      generateOTP: () => generateOtp(),
      expiresIn: 600,
      allowedAttempts: 5,
      disableSignUp: false,
    }),
  ],
})
The actual implementation in server/utils/auth.ts includes email sending, per-email OTP throttling, database hooks for UserData creation, trusted origins for Capacitor, and email-change verification. The simplified version above shows the core configuration structure.

What's configured

  • Email templates - Custom HTML templates with automatic variable replacement (logo, site name, URLs).
  • Email sending - Resend integration when RESEND_API_KEY is set; otherwise OTPs are logged to the server console so local/Ionic auth still works.
  • Database hooks - On sign up, creates the UserData record, links any purchase made under that address before the account existed, and sends the welcome email. Marketing consent is recorded separately by the sign-up page (see Marketing opt-in).
  • Email change - Link-based flow for the Nuxt settings UI; OTP endpoints (/api/auth/email-otp/request-email-change and /change-email) for mobile clients.
  • Rate limiting - Per-IP limits in better-auth (memory storage) plus a Postgres-backed per-email send throttle inside sendVerificationOTP (shared across instances).
  • CORS - server/middleware/00-cors.ts exposes set-auth-token for Capacitor clients.

Rate limiting and client IP

Limits are bucketed per client IP, resolved from x-forwarded-for and then x-real-ip. better-auth only trusts x-forwarded-for when it holds a single address, so deployments behind more than one hop (a CDN in front of a platform edge, for example) must declare those hops:

BETTER_AUTH_TRUSTED_PROXIES=173.245.48.0/20,10.0.0.0/8

Without this, the IP cannot be resolved and every client shares one rate-limit bucket. Leave the variable empty when the app is not proxied or when the proxy sets a single-address header.

The per-email throttle (5 sends per 15 minutes) is deliberately independent of the IP buckets: mobile clients behind carrier NAT share an IP, so the email address is the only reliable key for protecting an inbox from OTP spam. Unlike the per-IP limits, that counter lives in Postgres (auth_throttle) so it holds across serverless and multi-instance deploys. Expired rows are removed daily by the auth:prune-throttle Nitro task (same 3am schedule as account purge).

UserData is a custom database table for storing user preferences and other data points beyond what better-auth provides (for example, isTrialEligible for payments and marketingOptIn from sign up). It's not essential to the authentication system and can be removed if you don't need it. User data is accessible through the user Pinia store.

To customize any of these, edit server/utils/auth.ts directly.

Key features explained

OTP sign up and sign in

There are two entry points, /auth/sign-up and /auth/sign-in. Both run the same flow: request OTP → verify OTP → session.

  1. User enters their email (plus the marketing opt-in on sign up)
  2. System sends a 6-digit code to their email
  3. User enters the code to authenticate
  4. If the email is new, better-auth creates the account (disableSignUp: false)
  5. Session is created (cookie for web; set-auth-token header for bearer/mobile)

Both pages render app/components/auth/OtpAuthForm.vue, which takes a mode prop of 'sign-up' or 'sign-in'. The mode controls the copy and whether the marketing opt-in checkbox renders.

The code entry itself lives in app/components/auth/OtpCodeEntry.vue — the code input, the resend cooldown and the signIn.emailOtp() call. It discards any character the code cannot contain, so a code pasted with stray whitespace still works, then verifies as soon as the code reaches full length. It takes an email prop and emits verified, leaving what happens next to the parent, which is what lets /checkout/success reuse it to claim a guest purchase.

The split is presentational. better-auth's disableSignUp is a plugin-level option, not per-request, so both pages call the same /sign-in/email-otp endpoint. An unknown email entered on the sign-in page still creates an account, and a known email on the sign-up page simply signs in. Set disableSignUp: true if you need to turn account creation off entirely — but note that disables sign up on both pages.
OTP codes are 6 digits by default, expire after 10 minutes, and allow 5 verification attempts. Sends are also throttled per email address (5 per 15 minutes).

The shape of the code comes from shared/config/otp.config.ts. Changing OTP_ALPHABET (to issue alphanumeric codes) or OTP_LENGTH is enough on its own — the same two values drive code generation on the server, the input's length and keyboard on the client, and the sanitizer that runs before better-auth verifies. Expiry and attempt limits stay in server/utils/auth.ts.

Sign in with X

Off by default. server/utils/auth.ts registers the provider only when both TWITTER_CLIENT_ID and TWITTER_CLIENT_SECRET are set, and the button on /auth/sign-in and /auth/sign-up is hidden otherwise, so a deploy without credentials never advertises a flow it cannot complete.

To enable it:

  1. Create an OAuth 2.0 app in the X developer portal.
  2. Register <BETTER_AUTH_URL>/api/auth/callback/twitter as the callback URL. better-auth's provider key is twitter, not x, and that key is what fixes the path.
  3. Request the app permission that lets X return the user's email address. This may require a specific access tier; without it X withholds the address and sign-in is refused (see below).
  4. Put the client ID and secret in .env and restart.

The provider requests users.read, tweet.read and users.email. That is better-auth's default set for twitter minus offline.access.

offline.access is the one worth dropping: it makes better-auth persist a long-lived refresh token on every Account row, which is a durable ability to read the user's timeline. Nothing in this app reads either stored token, so the credential would exist for no feature. Widening later costs a re-consent prompt, but so does any future posting feature — tweet.write is not among the defaults either — so nothing is saved by asking for more now.

tweet.read cannot be dropped alongside it, despite reading like a scope a sign-in has no use for. X requires users.readandtweet.read on GET /2/users/me, which is the endpoint the provider's getUserInfo calls. Without it the profile fetch returns 403, getUserInfo returns null, and every sign-in dies at the callback with error=unable_to_get_user_info — after the user has already consented on X.

Both disableDefaultScope: true and scope are needed. A supplied scope is appended to the provider's defaults rather than replacing them, so scope on its own would leave offline.access in place.

X only returns an email address when your app holds the access tier that the users.email scope requires. Without one, better-auth's twitter provider falls back to the user's X handle in place of an address, which would create an account with a handle in the email column. mapProfileToUser in server/utils/auth.ts overrides that fallback with null, so the sign-in is refused before anything is written and the user is told to add a confirmed email to their X account or use a one-time code. Removing that override reintroduces the corrupt row.

Account linking

account.accountLinking is left at better-auth's defaults, which link an X account to an existing user only when the provider reports the email as verified. The twitter provider sets that flag from a confirmed_email lookup against X's API, not from the account's blue check, so it is a real confirmation signal.

An existing email-OTP user therefore links silently when X confirms the same address, and is refused with error=account_not_linked when it does not. Neither case can create a second user with a duplicate address: better-auth looks the email up before it inserts.

Error handling

OAuth failures do not reach the caller. better-auth's callback answers each one with a redirect to errorCallbackURL carrying ?error=<code>, which the auth client's throw option and useErrorHandler both bypass by design. app/components/auth/XAuthButton.vue reads the code on mount, shows a message for email_not_found and account_not_linked, stays silent when the user cancelled on X's consent screen, and clears the parameter so a refresh does not replay it.

One class of failure does not reach that handler. errorCallbackURL is carried inside the OAuth state, so when the state itself is missing or stale — the consent screen was left open past the cookie's lifetime, or cookies were cleared mid-flow — better-auth has nowhere to read it from and falls back to its own error page at /api/auth/error. That page is self-contained and readable but unbranded. Setting onAPIError.errorURL would redirect those to an app page instead, at the cost of also moving the link-based email-verification failures, which currently land there and would need matching copy.

Marketing opt-in

The checkbox is on the email step of the sign-up page only. It is not sent with the OTP request. Once the session exists, the form posts to a dedicated endpoint:

server/api/user-data/marketing-opt-in.post.ts
// No request body by design: posting here *is* the consent.
export default defineEventHandler(async event => {
  const userId = await requireAuth(event)
  await grantMarketingOptIn(userId)

  return { success: true }
})

grantMarketingOptIn in server/services/user-data-server-service.ts upserts marketingOptIn: true. Two properties are deliberate:

  • Grant-only. There is no boolean anywhere in the path, so no caller can revoke consent. An unticked box sends no request at all — leaving it unchecked is not a withdrawal of consent given earlier. A settings toggle that must also turn consent off needs its own endpoint.
  • Works for existing users. Because it runs after sign-in rather than inside better-auth's user-creation hook, it also applies to someone who already has an account.

The write is best-effort: if it fails, the completed sign up is not rolled back and the error is swallowed silently.

Email change

The Nuxt settings form (ChangeEmailForm.vue) uses better-auth's link-based changeEmail flow and lands on /auth/verify-email. A verification link goes to the new address, and the account email only changes once that link is opened.

user.changeEmail.enabled on its own is not enough. better-auth sends the link through the top-level emailVerification.sendVerificationEmail callback, and /change-email returns 400 "Verification email isn't enabled" if that callback is missing. Both options live in server/utils/auth.ts.

If you also want to notify the current address before the change takes effect, add a user.changeEmail.sendChangeEmailConfirmation callback. It only fires when emailVerification.sendVerificationEmail is also set and the current address is already verified.

Mobile clients should use the email-OTP plugin endpoints:

  • POST /api/auth/email-otp/request-email-change with { newEmail }
  • POST /api/auth/email-otp/change-email with { newEmail, otp }

Session management

Sessions are managed efficiently with:

  • 60-day expiration - Sessions last 60 days by default.
  • Cookie caching - Reduces database queries by caching session data (web).
  • Bearer tokens - Ionic/Capacitor clients send Authorization: Bearer ….
  • Automatic refresh - Sessions update every 24 hours.
  • Secure cookies - HTTP-only, secure, and SameSite protected (web).

requireAuth forwards request headers to better-auth's getSession, so bearer tokens work on every protected API route.

Mobile apps

A companion iOS or Android app — Ionic/Capacitor, React Native, or fully native — signs in with email OTP and carries the returned bearer token instead of a cookie. The same applies to any other non-browser client, such as a CLI or another backend service.

Client-side usage

Auth client

The auth client is initialized in app/utils/auth-client.ts:

app/utils/auth-client.ts
import { createAuthClient } from 'better-auth/vue'
import { emailOTPClient } from 'better-auth/client/plugins'

export const authClient = createAuthClient({
  plugins: [emailOTPClient()],
  fetchOptions: { throw: true },
})

The authClient is auto-imported and available throughout your app. The Nuxt web app uses cookie sessions, so it does not mount a bearer client plugin.

fetchOptions: { throw: true } is load-bearing. better-auth's client resolves with { data, error } by default, so a failed call is indistinguishable from a successful one unless every caller reads error — a wrong OTP would fall through to the success path. Every call site in the template wraps authClient calls in try/catch and depends on this. If you override fetchOptions on an individual call, re-spread it.

Usage examples

To see real-world usage of the auth client, check out these components in the template:

  • app/components/auth/OtpAuthForm.vue - OTP sign up / sign in
  • app/components/auth/XAuthButton.vue - Sign in with X, including OAuth error handling
  • app/components/auth/OtpCodeEntry.vue - Reusable code entry, resend and verification
  • app/components/settings/ChangeEmailForm.vue - Email change
  • app/components/settings/ChangeNameForm.vue - Update user info
  • app/stores/user.ts - Session management and sign out

For the complete client API reference and all available methods, see the better-auth client documentation.

User store

The centralized user store (app/stores/user.ts) provides reactive authentication state:

const userStore = useUserStore()
const { user, session, isAuthenticated, subscription } = storeToRefs(userStore)

Protecting pages

Pages are automatically protected by default via the global auth middleware (app/middleware/auth.global.ts). Any route that isn't in the public routes list requires authentication and will redirect unauthenticated users to /auth/sign-in.

You don't need to add any middleware to protect pages - they're secure out of the box.

Making pages public

To make a specific page public (accessible without authentication), you have two options:

Option 1: Use the public layout

<script setup>
definePageMeta({
  layout: 'public',
})
</script>

Option 2: Add to the public routes list

Edit app/middleware/auth.global.ts and add your route to either:

  • publicPrefixes - For route prefixes (e.g., /blog/ makes all blog routes public)
  • exactPublicRoutes - For exact path matches (e.g., /download)
app/middleware/auth.global.ts
const publicPrefixes = ['/auth', '/ai', '/blog', '/checkout', '/docs', '/templates']
const exactPublicRoutes = ['/', '/download', '/contact', '/roadmap', '/changelog']

Why secure by default?

This template implements secure by default authentication: all routes start as protected, and you explicitly mark which ones should be public. This is a security best practice where systems are configured with maximum security from the outset.

The key advantage: If you forget to configure a route, it fails closed (protected) rather than fails open (exposed). This prevents accidental data exposure.

Protecting API endpoints

Use the requireAuth utility in your API routes:

server/api/protected.ts
export default defineEventHandler(async event => {
  const userId = await requireAuth(event)

  // If you need the full user object (email, name, etc.):
  const user = event.context.user

  return {
    message: `Hello ${user.name}!`,
  }
})

Authentication pages

The boilerplate includes pre-built authentication pages:

  • /auth/sign-up - Passwordless OTP sign up, with the marketing opt-in checkbox
  • /auth/sign-in - Passwordless OTP sign in

Both render the "Continue with X" button above the OTP form when X credentials are configured.

  • /auth/verify-email - Landing page for link-based email change verification
  • /auth/account-deleted - Soft-deleted account grace period

All pages are styled with shadcn-vue components and follow best practices.

Email templates

Email templates are HTML-based and located in server/email-templates/:

  • otpTemplate.ts - OTP code delivery
  • verifyEmailTemplate.ts - Email verification, used for both sign up and email change
Templates include placeholders like {{otp}}, {{verificationUrl}}, and {{site_name}} that are replaced with actual values.

Customizing email templates

To customize an email template:

  1. Open the template file in server/email-templates/
  2. Modify the HTML as needed
  3. Keep placeholders for dynamic content
  4. Test by triggering the email flow

Security features

Better-auth provides built-in security features:

  • CSRF protection - Built-in CSRF token validation
  • Secure cookies - HTTP-only, Secure, and SameSite attributes
  • Rate limiting - Per-IP request limits (memory) plus Postgres-backed per-email OTP send throttling
  • OTP attempt limits - Codes invalidate after too many failed attempts
  • Trusted origins - Capacitor / Ionic origins plus BETTER_AUTH_TRUSTED_ORIGINS

Database schema

Better-auth uses these Prisma models (see prisma/schema.prisma):

  • User - User accounts
  • Session - Active sessions (@@unique([token]), @@index([userId]))
  • Account - Linked auth accounts (one row per X sign-in; unused by email OTP, which stores nothing here)
  • Verification - OTP / verification tokens

Run pnpm db:push after pulling schema changes.

Further reading