This is a demo site.Purchase now

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, 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)
  • 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=
  • 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 .
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: 6,
      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 - Automatically creates UserData records when users sign up. Marketing consent is recorded separately by the sign-up page (see ).
  • 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 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, expire after 10 minutes, and allow 5 verification attempts. Sends are also throttled per email address (5 per 15 minutes).

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.

See mobile apps for the endpoint reference, the app-side setup, and which environment variables each kind of client needs.

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()],
})

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.

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/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
  • /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 (unused for passwordless OTP, retained by better-auth)
  • Verification - OTP / verification tokens

Run pnpm db:push after pulling schema changes.

Further reading