Authentication
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 usingopenssl rand -base64 32.BETTER_AUTH_URL- The base URL of your application. Usehttp://localhost:3000for 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.
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:
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,
}),
],
})
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_KEYis set; otherwise OTPs are logged to the server console so local/Ionic auth still works. - Database hooks - Automatically creates
UserDatarecords 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-changeand/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.tsexposesset-auth-tokenfor 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.
- User enters their email (and optional marketing opt-in) on the login form
- System sends a 6-digit code to their email
- User enters the code to authenticate
- If the email is new, better-auth creates the account (
disableSignUp: false) - Session is created (cookie for web;
set-auth-tokenheader 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:
databaseHooks: {
user: {
create: {
after: async (user, ctx) => {
const marketingOptIn = ctx?.body?.marketingOptIn === true
await prisma.userData.create({
data: {
userId: user.id,
marketingOptIn,
},
})
},
},
},
},
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-changewith{ newEmail }POST /api/auth/email-otp/change-emailwith{ 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:
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 / loginapp/components/settings/ChangeEmailForm.vue- Email changeapp/components/settings/ChangeNameForm.vue- Update user infoapp/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)
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:
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 deliverychangeEmailTemplate.ts- Email change confirmation
{{otp}}, {{confirmationUrl}}, and {{site_name}} that are replaced with actual values.Customizing email templates
To customize an email template:
- Open the template file in
server/email-templates/ - Modify the HTML as needed
- Keep placeholders for dynamic content
- 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 accountsSession- 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.