X Enterprises

nuxt-x-auth-local

Self-hosted JWT authentication Nuxt layer — useXAuth composable, 4 pre-built UI components, global route middleware, automatic token refresh, and full endpoint configurability for your own backend.

nuxt-x-auth-local

Self-hosted JWT authentication layer for Nuxt. Connects to your own backend API — any server that accepts email/password and returns a JWT works. Provides useXAuth with shared state via useState, automatic token refresh on 401, cookie-based token storage, 4 pre-built auth UI components, and a global route guard.

Installation

npm install @xenterprises/nuxt-x-auth-local
// nuxt.config.ts
export default defineNuxtConfig({
  extends: ['@xenterprises/nuxt-x-auth-local'],
})

What the consumer writes

The layer ships batteries included: auth pages (/auth/login, /auth/signup, /auth/forgot-password, /auth/reset-password, /auth/logout), an auth layout, global route middleware, and all components/composables auto-registered. The minimal consumer is an extends entry and one env var.

// nuxt.config.ts
export default defineNuxtConfig({
  extends: ['@xenterprises/nuxt-x-auth-local'],
})
# .env — your JWT backend base URL
NUXT_PUBLIC_LOCAL_AUTH_BASE_URL=http://localhost:4000
// app/app.config.ts — optional overrides; must live in app/, not the project root
export default defineAppConfig({
  xAuth: {
    redirects: {
      afterLogin: '/dashboard',
    },
    ui: {
      logoUrl: '/logo.svg',
      form: { icon: 'i-lucide-shield' },
    },
  },
})

Everything except NUXT_PUBLIC_LOCAL_AUTH_BASE_URL is optional — the layer's own app.config.ts carries working defaults (signup and forgot-password enabled, /auth/* redirects, default cookie names). Every default page, redirect, and endpoint is overridable via the xAuth namespace / runtimeConfig.public.localAuth, or via standard Nuxt page overriding.

Requirements and notes:

  • SPA mode. The layer sets ssr: false. Do not re-enable SSR in the consumer.
  • Peer dependencies: nuxt ^4.0.0. The layer depends on @nuxt/ui ^4.3.0 and zod ^3.24.0.
  • baseUrl may be left empty ('') to call a same-origin API — endpoint paths are then resolved against the Nuxt origin.

Same-origin mock backend (playground pattern)

The layer's .playground ships a same-origin mock backend so it runs with no external API. Copy the pattern into a consumer (or a demo environment) by adding two server routes — with baseUrl: '' the layer calls them directly:

// server/routes/auth/login.post.ts — accepts any non-empty credentials
export default defineEventHandler(async (event) => {
  const body = await readBody<{ email?: string; password?: string }>(event)

  if (!body?.email || !body?.password) {
    throw createError({ statusCode: 400, message: 'Email and password are required' })
  }

  return {
    accessToken: 'mock-access-token',
    refreshToken: 'mock-refresh-token',
    user: {
      id: '1',
      email: body.email,
      name: 'Demo User',
      emailVerified: true,
    },
  }
})
// server/routes/auth/me.get.ts — returns the demo user when authorized
export default defineEventHandler((event) => {
  const authorization = getHeader(event, 'authorization')

  if (!authorization?.startsWith('Bearer ')) {
    throw createError({ statusCode: 401, message: 'Unauthorized' })
  }

  return {
    user: {
      id: '1',
      email: 'demo@example.com',
      name: 'Demo User',
      emailVerified: true,
    },
  }
})

This exercises the full local JWT flow — login → token cookies → Bearer-authenticated /auth/me → redirect to redirects.afterLogin — entirely same-origin. Point NUXT_PUBLIC_LOCAL_AUTH_BASE_URL at a real backend for production.

Configuration

runtimeConfig.public.localAuth

All endpoint paths are relative to baseUrl and have sensible defaults.

KeyDefaultDescription
baseUrl""Backend API base URL. Leave empty for a same-origin API.
loginEndpoint"/auth/login"POST endpoint for email/password login.
signupEndpoint"/auth/signup"POST endpoint for registration.
logoutEndpoint"/auth/logout"POST endpoint for logout.
refreshEndpoint"/auth/refresh"POST endpoint for token refresh.
userEndpoint"/auth/me"GET endpoint to fetch the current user.
forgotPasswordEndpoint"/auth/forgot-password"POST endpoint to request a reset email.
resetPasswordEndpoint"/auth/reset-password"POST endpoint to apply a new password.
changePasswordEndpoint"/auth/change-password"POST endpoint for authenticated password change.

app.config.ts

export default defineAppConfig({
  xAuth: {
    tokens: {
      accessCookie: 'x_auth_access',   // cookie name for access token
      refreshCookie: 'x_auth_refresh',  // cookie name for refresh token
      hasRefresh: true,                 // set false if your API has no refresh token
    },

    redirects: {
      login: '/auth/login',
      signup: '/auth/signup',
      afterLogin: '/',
      afterSignup: '/',
      afterLogout: '/auth/login',
      forgotPassword: '/auth/forgot-password',
    },

    features: {
      forgotPassword: true,
      signup: true,
      routeProtection: true,        // set false to disable the global route middleware
    },

    ui: {
      showLogo: true,
      logoUrl: undefined,          // e.g. '/logo.svg'
      brandName: undefined,        // shown above the auth card
      tagline: undefined,          // shown under the brand name
      form: {
        icon: undefined,           // e.g. 'i-lucide-shield'; per-form fallbacks apply
        showSeparator: true,
      },
    },
  },
})

useXAuth Composable

State is shared across components via Nuxt's useState — all instances of useXAuth on the same page share the same user and isLoading refs.

const {
  // State
  user,            // Ref<AuthUser | null>  — shared via useState
  isLoading,       // Ref<boolean>          — shared via useState
  isAuthenticated, // ComputedRef<boolean>
  emailSent,       // Ref<boolean>          — true after forgotPassword

  // Core
  login,           // (email, password) => Promise<AuthUser | null>
  signup,          // (email, password) => Promise<AuthUser | null>
  logout,          // () => Promise<true>

  // Password management
  forgotPassword,  // (email) => Promise<boolean>
  resetPassword,   // (token, newPassword) => Promise<true | { error }>
  changePassword,  // (currentPassword, newPassword) => Promise<true | { error }>

  // Token / session
  refreshToken,    // () => Promise<AuthTokens | null>
  getCurrentUser,  // () => Promise<AuthUser | null>
  getToken,        // () => string | null  (synchronous)
  getAuthHeaders,  // () => Record<string, string>  (synchronous)

  resetState,      // () => void — clears emailSent
  config,          // ComputedRef<AuthConfig>
} = useXAuth()

AuthUser shape

{
  id: string
  email: string
  name: string
  avatar?: string
  emailVerified: boolean
  metadata?: Record<string, any>
}

User data is normalized from your API response using fieldMapper — it tries common field name variants (id/sub/user_id, name/displayName/full_name, etc.) automatically.

Expected API response shapes

Login / Signup:

{ "accessToken": "eyJ...", "refreshToken": "eyJ..." }

Also accepts token as an alias for accessToken.

Current user (/auth/me):

{ "user": { "id": "1", "email": "user@example.com", "name": "Jane" } }

Or a flat object: { "id": "1", "email": "...", "name": "..." }.

Token refresh (/auth/refresh):

{ "accessToken": "eyJ...", "refreshToken": "eyJ..." }

Automatic token refresh

On any 401 response, the composable automatically calls refreshEndpoint with the stored refresh token and retries the original request once. If refresh fails, cookies are cleared. Disable this by setting tokens.hasRefresh: false.

Security

All redirect targets are validated to be relative paths (starting with /). Protocol-relative (//) and absolute URLs are silently replaced with / to prevent open redirect attacks.

Examples

// Login
const user = await login('user@example.com', 'password')

// Protect an API call
const headers = getAuthHeaders()
// { Authorization: 'Bearer eyJ...' }

// Change password (authenticated)
const result = await changePassword('oldPass', 'newPass')
if (result !== true) console.error(result.error)

// Reset password from email link
const result = await resetPassword(route.query.token, newPassword)

Pre-Built Pages

RouteDescription
/auth/loginLogin form
/auth/signupRegistration form (if features.signup: true)
/auth/forgot-passwordPassword reset request form
/auth/reset-passwordNew password form (reads token from query string)
/auth/logoutCalls logout() and redirects

Components

All 4 components are auto-imported with the XAuth prefix.

ComponentDescription
<XAuthLogin />Email/password login form with forgot-password link
<XAuthSignup />Registration form
<XAuthForgotPassword />Password reset request; shows success state after submit
<XAuthForm />Shared fields-driven, Zod-validated form shell used by the page components

Global Route Middleware

auth.global.ts runs on every navigation. Guest-only routes are derived from redirects.login / redirects.signup / redirects.forgotPassword / redirects.resetPassword (with /auth/* fallbacks), so overriding a redirect path moves the guest-only classification with it. Disable the middleware entirely with features.routeProtection: false if you manage your own route guards.

Route typeBehavior
Guest-only (login, signup, forgot-password, reset-password paths)Authenticated users → redirect to redirects.afterLogin
Public (/auth/logout)Always allowed
All other routesUnauthenticated users → redirect to redirects.login

Token Storage

Tokens are stored in cookies (names configured via tokens.accessCookie and tokens.refreshCookie; SameSite=Lax, Secure in production, ~1h access / 7d refresh max-age). The auth-token plugin provides $getAuthToken so other layers and plugins can read the current access token synchronously:

const { $getAuthToken } = useNuxtApp()
const token = $getAuthToken() // string | null

Outgoing API calls are not intercepted automatically — pass getAuthHeaders() from useXAuth (or $getAuthToken()) with your own $fetch calls.

Environment Variables

VariableDescription
NUXT_PUBLIC_LOCAL_AUTH_BASE_URLBackend API base URL (leave unset/empty for a same-origin API)
NUXT_PUBLIC_LOCAL_AUTH_LOGIN_ENDPOINTOverride default /auth/login
NUXT_PUBLIC_LOCAL_AUTH_SIGNUP_ENDPOINTOverride default /auth/signup
NUXT_PUBLIC_LOCAL_AUTH_LOGOUT_ENDPOINTOverride default /auth/logout
NUXT_PUBLIC_LOCAL_AUTH_REFRESH_ENDPOINTOverride default /auth/refresh
NUXT_PUBLIC_LOCAL_AUTH_USER_ENDPOINTOverride default /auth/me
NUXT_PUBLIC_LOCAL_AUTH_FORGOT_PASSWORD_ENDPOINTOverride default /auth/forgot-password
NUXT_PUBLIC_LOCAL_AUTH_RESET_PASSWORD_ENDPOINTOverride default /auth/reset-password
NUXT_PUBLIC_LOCAL_AUTH_CHANGE_PASSWORD_ENDPOINTOverride default /auth/change-password

How It Works

On first call, useXAuth reads runtimeConfig.public.localAuth to resolve baseUrl and all endpoint paths. State (user, isLoading, emailSent) lives in Nuxt's useState so it is shared across all component instances without a Pinia store. fetchApi is an internal helper that injects the Bearer token header, handles 401 by attempting a single token refresh, then retries — callers never need to handle token expiry manually. forgotPassword always shows a success toast regardless of whether the email exists, preventing email enumeration. All redirect paths are validated against a safe-redirect guard before calling navigateTo.

Copyright © 2026