Authentication
Better Auth login, OAuth and route protection in the Nuxt template.
Authentication is Integrated end to end with Better Auth: email/password with required verification, password reset, GitHub/Google OAuth with account linking, sessions, and an admin plugin.
What it provides
- Email/password sign-up with required email verification and auto-sign-in after verification.
- Password reset by email.
- GitHub and Google OAuth, with account linking enabled.
- The admin plugin (roles, ban fields) and a role-guarded admin users API.
- Two layers of route protection: client middleware for UX, API checks for authority.
Important files
| File | Role |
|---|---|
server/utils/auth.ts | Better Auth server config (createBetterAuth / useServerAuth) |
server/api/auth/[...all].ts | Catch-all handler that mounts Better Auth's routes |
server/middleware/auth.ts | Attaches event.context.auth on every request; exports requireAuth / getAuthContext |
app/composables/useAuth.ts | Vue client (createAuthClient); exports signIn/signUp/signOut/useSession/getSession |
app/middleware/auth.ts | Client route guard — redirects to login when no session |
app/middleware/admin.ts | Client route guard — requires role === "admin" |
app/pages/auth/* | login, register, register-success, confirm, forgot-password, reset-password (use the auth layout) |
Server setup
createBetterAuth() in server/utils/auth.ts wires the Drizzle adapter
(provider: "pg") to the auth schema and configures:
baseURLandtrustedOriginsderived fromruntimeConfig.public.baseUrl, plus any comma-separated origins inNUXT_AUTH_EXTRA_TRUSTED_ORIGINS.emailAndPassword: enabled,requireEmailVerification: true,sendResetPassword→sendEmailwith theforgotPasswordtemplate.emailVerification:sendOnSignUp: true,autoSignInAfterVerification: true,sendVerificationEmail→sendEmailwith theverifyEmailtemplate.socialProviders: GitHub and Google from theirNUXT_*_CLIENT_ID/SECRET.account.accountLinking.enabled: true.plugins: [admin()].
Verification and reset emails are rendered by the Vue Email templates via
sendEmail(...) — there is no inline-HTML path. Without NUXT_RESEND_API_KEY,
these emails can't send and a new account can't complete verification. See
Email.
Client setup
app/composables/useAuth.ts creates the client with createAuthClient from
better-auth/vue, registers the adminClient plugin, and re-exports
signIn, signUp, signOut, useSession, and getSession. The auth pages use
these against the auth layout.
OAuth providers
Set a provider's pair of variables to enable it (see Environment Variables):
- GitHub —
NUXT_GITHUB_CLIENT_ID,NUXT_GITHUB_CLIENT_SECRET - Google —
NUXT_GOOGLE_CLIENT_ID,NUXT_GOOGLE_CLIENT_SECRET
Use a callback URL of <NUXT_PUBLIC_BASE_URL>/api/auth/callback/<provider> in the
provider's console. When a provider's credentials aren't configured, hide or
remove its button on the login/register pages.
Two-layer route protection
Protection is defense-in-depth: the client guards are for UX, and the API handlers are authoritative.
Client route guards (UX)
app/middleware/auth.ts and app/middleware/admin.ts run on the client only
(they return early during SSR). They read the cached reactive useSession()
store — no per-navigation server request — and:
auth→ redirects to/auth/loginwhen there is no session.admin→ redirects to/auth/loginwhen logged out, or to/dashboardwhen the user'sroleis notadmin.
Pages opt in with definePageMeta:
// app/pages/(dashboard)/dashboard.vue
definePageMeta({ middleware: "auth" });
// app/pages/(dashboard)/admin/users.vue
definePageMeta({ middleware: ["auth", "admin"] });API checks (authoritative)
server/middleware/auth.ts runs on every request and only attaches
event.context.auth. The real enforcement lives in each handler. For example,
server/api/admin/users.get.ts calls requireAuth(event) (401 if absent) and
then returns 403 when currentUser.role !== "admin":
const { user: currentUser } = requireAuth(event);
if (currentUser.role !== "admin") {
throw createError({ statusCode: 403, statusMessage: "Forbidden - Admin role required" });
}A page is only protected if it declares middleware in definePageMeta, and a
page guard alone is not security. Any data-fetching route must do its own
requireAuth (and role) check — the client guard can be bypassed.
Admin role
Roles come from the admin plugin; role and the banned/banReason/
banExpires fields live on the user table. Promote a user by setting
role = "admin" (e.g. in Drizzle Studio) — then ["auth", "admin"] pages and
the admin users API become available to them.
Verify
- Register at
/auth/register; a verification email is sent. - Verify the email; you're auto-signed-in.
- Log out and back in with email/password.
- OAuth login works for any configured provider.
- Password reset email arrives and resets the password.
- A logged-out visit to
/dashboardredirects to/auth/login. - A non-admin visit to
/admin/usersredirects to/dashboard, and the/api/admin/userscall returns 403.
