Authentication
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 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.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.
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: OTP_LENGTH,
generateOTP: () => generateOtp(),
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 - On sign up, creates the
UserDatarecord, 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-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 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.
- User enters their email (plus the marketing opt-in on sign up)
- 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)
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.
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.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:
- Create an OAuth 2.0 app in the X developer portal.
- Register
<BETTER_AUTH_URL>/api/auth/callback/twitteras the callback URL. better-auth's provider key istwitter, notx, and that key is what fixes the path. - 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).
- Put the client ID and secret in
.envand 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.
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:
// 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-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()],
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 inapp/components/auth/XAuthButton.vue- Sign in with X, including OAuth error handlingapp/components/auth/OtpCodeEntry.vue- Reusable code entry, resend and verificationapp/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/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)
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/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 deliveryverifyEmailTemplate.ts- Email verification, used for both sign up and email change
{{otp}}, {{verificationUrl}}, 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 (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.
