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 signup/login, bearer sessions for mobile clients, email change, and session management.

Overview

The authentication system provides:

  • Passwordless email OTP for signup and login (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 rate limiting.
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, including the marketing opt-in preference from the OTP login form.
  • 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 login). 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 signup and login

There is a single entry point at /auth/login. Auth is: request OTP → verify OTP → session.

  1. User enters their email (and optional marketing opt-in) on the login form
  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)

The marketing opt-in checkbox is on the email step of app/components/auth/OtpLoginForm.vue. The value is passed on signIn.emailOtp and saved to UserData.marketingOptIn in the user creation database hook:

server/utils/auth.ts
databaseHooks: {
  user: {
    create: {
      after: async (user, ctx) => {
        const marketingOptIn = ctx?.body?.marketingOptIn === true

        await prisma.userData.create({
          data: {
            userId: user.id,
            marketingOptIn,
          },
        })
      },
    },
  },
},
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).

Email change

The Nuxt settings form (ChangeEmailForm.vue) uses better-auth's link-based changeEmail flow and lands on /auth/verify-email.

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/OtpLoginForm.vue - OTP signup / login
  • 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/login.

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/login - Passwordless OTP signup / login
  • /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
  • changeEmailTemplate.ts - Email change confirmation
Templates include placeholders like {{otp}}, {{confirmationUrl}}, 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