nuxt-x-auth-local
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.0andzod^3.24.0. baseUrlmay 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.
| Key | Default | Description |
|---|---|---|
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
| Route | Description |
|---|---|
/auth/login | Login form |
/auth/signup | Registration form (if features.signup: true) |
/auth/forgot-password | Password reset request form |
/auth/reset-password | New password form (reads token from query string) |
/auth/logout | Calls logout() and redirects |
Components
All 4 components are auto-imported with the XAuth prefix.
| Component | Description |
|---|---|
<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 type | Behavior |
|---|---|
| Guest-only (login, signup, forgot-password, reset-password paths) | Authenticated users → redirect to redirects.afterLogin |
Public (/auth/logout) | Always allowed |
| All other routes | Unauthenticated 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
| Variable | Description |
|---|---|
NUXT_PUBLIC_LOCAL_AUTH_BASE_URL | Backend API base URL (leave unset/empty for a same-origin API) |
NUXT_PUBLIC_LOCAL_AUTH_LOGIN_ENDPOINT | Override default /auth/login |
NUXT_PUBLIC_LOCAL_AUTH_SIGNUP_ENDPOINT | Override default /auth/signup |
NUXT_PUBLIC_LOCAL_AUTH_LOGOUT_ENDPOINT | Override default /auth/logout |
NUXT_PUBLIC_LOCAL_AUTH_REFRESH_ENDPOINT | Override default /auth/refresh |
NUXT_PUBLIC_LOCAL_AUTH_USER_ENDPOINT | Override default /auth/me |
NUXT_PUBLIC_LOCAL_AUTH_FORGOT_PASSWORD_ENDPOINT | Override default /auth/forgot-password |
NUXT_PUBLIC_LOCAL_AUTH_RESET_PASSWORD_ENDPOINT | Override default /auth/reset-password |
NUXT_PUBLIC_LOCAL_AUTH_CHANGE_PASSWORD_ENDPOINT | Override 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.
