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

FileRole
server/utils/auth.tsBetter Auth server config (createBetterAuth / useServerAuth)
server/api/auth/[...all].tsCatch-all handler that mounts Better Auth's routes
server/middleware/auth.tsAttaches event.context.auth on every request; exports requireAuth / getAuthContext
app/composables/useAuth.tsVue client (createAuthClient); exports signIn/signUp/signOut/useSession/getSession
app/middleware/auth.tsClient route guard — redirects to login when no session
app/middleware/admin.tsClient 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:

  • baseURL and trustedOrigins derived from runtimeConfig.public.baseUrl, plus any comma-separated origins in NUXT_AUTH_EXTRA_TRUSTED_ORIGINS.
  • emailAndPassword: enabled, requireEmailVerification: true, sendResetPasswordsendEmail with the forgotPassword template.
  • emailVerification: sendOnSignUp: true, autoSignInAfterVerification: true, sendVerificationEmailsendEmail with the verifyEmail template.
  • socialProviders: GitHub and Google from their NUXT_*_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/login when there is no session.
  • admin → redirects to /auth/login when logged out, or to /dashboard when the user's role is not admin.

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 /dashboard redirects to /auth/login.
  • A non-admin visit to /admin/users redirects to /dashboard, and the /api/admin/users call returns 403.

On this page