@dommidev10/nuxt-jwt-auth
v1.0.0
Published
Nuxt 3 authentication module with 2FA, password reset, and email verification
Maintainers
Readme
@dommidev10/nuxt-jwt-auth
A fully configurable Nuxt 3 authentication module with JWT support, refresh token rotation, two-factor authentication, password reset, and email verification.
Why Use This Module?
The Problem
Implementing authentication in Nuxt 3 applications typically requires:
- Repetitive boilerplate - Token management, cookie handling, API calls
- SSR complexity - Managing tokens across server and client contexts
- Security concerns - Proper token refresh, cookie security attributes
- Multiple flows - Login, logout, 2FA, password reset, email verification
- API flexibility - Every backend has different response structures
The Solution
@dommidev10/nuxt-jwt-auth provides a transport-agnostic authentication layer that:
| Challenge | Solution |
|-----------|----------|
| Different API field names | Body mapping - Map email → username, password → pwd |
| Different response structures | JSON pointers - Extract tokens from any response path |
| Token refresh complexity | Automatic refresh - Schedules refresh before expiration |
| SSR hydration issues | Built-in SSR support - Cookies work seamlessly |
| Multiple auth flows | Modular composables - Enable only what you need |
Who Should Use This?
✅ Use this module if you:
- Have an existing backend with JWT authentication
- Need SSR-compatible authentication
- Want configurable 2FA, password reset, or email verification
- Don't want to write token management boilerplate
- Need to adapt to various API response formats
❌ Consider alternatives if you:
- Use OAuth/Social login only (use
@sidebase/nuxt-auth) - Have a Supabase/Firebase backend (use their official SDKs)
- Need session-based authentication (not JWT)
Real-World Example: Before vs After
❌ Without This Module (150+ lines)
// composables/useAuth.ts - Manual implementation
export function useAuth() {
const token = useCookie('auth.token');
const refreshToken = useCookie('auth.refresh_token');
const session = useState<User | null>('auth:session', () => null);
const loading = useState('auth:loading', () => false);
const error = useState<string | null>('auth:error', () => null);
async function signIn(email: string, password: string) {
loading.value = true;
error.value = null;
try {
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password },
});
// Handle 2FA case
if (response.requiresTwoFactor) {
return { requiresTwoFactor: true, userId: response.userId };
}
// Extract tokens (what if nested? what if different names?)
token.value = response.accessToken;
refreshToken.value = response.refreshToken;
// Fetch session
await getSession();
// Schedule token refresh
scheduleRefresh();
return { requiresTwoFactor: false };
} catch (err) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
async function getSession() { /* ... */ }
async function refresh() { /* ... */ }
function scheduleRefresh() { /* ... */ }
async function signOut() { /* ... */ }
// ... 100+ more lines for refresh logic, middleware, etc.
return { token, session, signIn, signOut, /* ... */ };
}✅ With This Module (10 lines config)
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@dommidev10/nuxt-jwt-auth'],
auth: {
baseURL: 'https://api.example.com',
endpoints: {
signIn: { path: '/auth/login', method: 'post' },
signOut: { path: '/auth/logout', method: 'post' },
getSession: { path: '/auth/me', method: 'get' },
},
token: { signInResponseTokenPointer: '/accessToken' },
},
});<!-- pages/login.vue - That's it! -->
<script setup>
const { signIn, isLoading, error } = useAuth(); // Auto-imported!
async function handleLogin() {
const result = await signIn({ email, password });
if (!result.requiresTwoFactor) navigateTo('/dashboard');
}
</script>Key Benefits
1. Zero Lock-in
The module is a transport layer only. It doesn't:
- Dictate your backend structure
- Require specific database schemas
- Handle email sending (your backend does)
- Manage user storage
2. Incremental Adoption
Enable features as needed:
auth: {
// Start simple
twoFactor: { enabled: false },
passwordReset: { enabled: false },
emailVerification: { enabled: false },
// Enable later when ready
twoFactor: { enabled: true },
}3. Works with Any Backend
Whether your API returns:
// Laravel Sanctum style
{ "token": "...", "user": { "id": 1 } }
// Express/NestJS style
{ "accessToken": "...", "refreshToken": "..." }
// Nested response
{ "data": { "auth": { "jwt": "..." } } }Just configure the JSON pointers:
token: {
signInResponseTokenPointer: '/data/auth/jwt', // Works!
}4. Type-Safe
Full TypeScript support with autocompletion:
const { session } = useAuth();
session.value?.email // ✓ Typed
session.value?.role // ✓ Typed (with type augmentation)Features
- JWT Authentication - Complete token-based authentication with cookie storage
- Refresh Token Rotation - Automatic token refresh before expiration
- Two-Factor Authentication (2FA) - Configurable 2FA flow with code verification
- Password Reset - Complete forgot/reset password flow
- Email Verification - Email verification with resend capability
- Route Protection - Middleware for protecting routes (global or per-page)
- SSR Compatible - Works seamlessly with Nuxt's server-side rendering
- Fully Configurable - JSON pointers for API response extraction, body mapping for field names
- TypeScript Support - Full type definitions included
Table of Contents
- Why Use This Module?
- Installation
- Quick Start
- Configuration
- Composables
- Route Protection
- Complete Examples
- Practical Recipes
- JSON Pointers
- Body Mapping
- Backend Integration
- TypeScript
- Troubleshooting
- Development
- License
Installation
# Using pnpm (recommended)
pnpm add @dommidev10/nuxt-jwt-auth
# Using npm
npm install @dommidev10/nuxt-jwt-auth
# Using yarn
yarn add @dommidev10/nuxt-jwt-authQuick Start
1. Add the module to your Nuxt config
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@dommidev10/nuxt-jwt-auth'],
auth: {
baseURL: 'https://api.example.com',
endpoints: {
signIn: { path: '/auth/login', method: 'post' },
signOut: { path: '/auth/logout', method: 'post' },
getSession: { path: '/auth/me', method: 'get' },
},
token: {
signInResponseTokenPointer: '/accessToken',
},
pages: {
login: '/auth/login',
home: '/dashboard',
},
},
});2. Use the composable in your components
<script setup lang="ts">
const { signIn, signOut, session, isAuthenticated, isLoading, error } = useAuth();
const email = ref('');
const password = ref('');
async function handleLogin() {
try {
const result = await signIn({ email: email.value, password: password.value });
if (!result.requiresTwoFactor) {
navigateTo('/dashboard');
}
} catch (err) {
// Error is automatically set in the error ref
}
}
</script>
<template>
<div>
<div v-if="isAuthenticated">
<p>Welcome, {{ session?.email }}</p>
<button @click="signOut">Logout</button>
</div>
<form v-else @submit.prevent="handleLogin">
<input v-model="email" type="email" placeholder="Email" required />
<input v-model="password" type="password" placeholder="Password" required />
<div v-if="error" class="error">{{ error }}</div>
<button :disabled="isLoading">
{{ isLoading ? 'Signing in...' : 'Sign In' }}
</button>
</form>
</div>
</template>Configuration
Base Configuration
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| baseURL | string | '' | Base URL for all API requests |
| debug | boolean | false | Enable debug logging to console |
| refreshOnFocusChanged | boolean | false | Refresh session when browser tab regains focus |
auth: {
baseURL: 'https://api.example.com',
debug: process.env.NODE_ENV === 'development',
refreshOnFocusChanged: true,
}Endpoints
Configure the main authentication endpoints.
auth: {
endpoints: {
signIn: {
path: '/auth/login',
method: 'post',
body: {
email: 'email', // Maps 'email' → 'email' in request body
password: 'password', // Maps 'password' → 'password' in request body
},
},
signOut: {
path: '/auth/logout',
method: 'post',
},
getSession: {
path: '/auth/me',
method: 'get',
},
},
}Custom Field Names
If your API expects different field names:
auth: {
endpoints: {
signIn: {
path: '/auth/login',
method: 'post',
body: {
email: 'username', // Sends { username: '...' } instead of { email: '...' }
password: 'pwd', // Sends { pwd: '...' } instead of { password: '...' }
},
},
},
}Token Configuration
Configure how access tokens are handled.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| signInResponseTokenPointer | string | '/accessToken' | JSON pointer to extract token from login response |
| cookieName | string | 'auth.token' | Cookie name for storing the token |
| maxAgeInSeconds | number | 3600 | Cookie max age (1 hour) |
| sameSiteAttribute | 'lax' \| 'strict' \| 'none' | 'lax' | Cookie SameSite attribute |
| secureCookieAttribute | boolean \| undefined | undefined | Cookie Secure attribute (auto-detected if undefined) |
| httpOnlyCookieAttribute | boolean | false | Cookie HttpOnly attribute |
| type | string | 'Bearer' | Token type prefix in Authorization header |
| headerName | string | 'Authorization' | HTTP header name for sending token |
auth: {
token: {
signInResponseTokenPointer: '/data/accessToken', // For nested responses
cookieName: 'auth.token',
maxAgeInSeconds: 3600,
sameSiteAttribute: 'lax',
secureCookieAttribute: true, // Force secure cookies
httpOnlyCookieAttribute: false,
type: 'Bearer',
headerName: 'Authorization',
},
}Refresh Token
Enable automatic token refresh before expiration.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| enabled | boolean | false | Enable refresh token functionality |
| refreshOnlyToken | boolean | true | Only refresh token (not full session) |
| refreshBeforeExpiryInSeconds | number | 300 | Refresh token X seconds before expiry |
auth: {
refresh: {
enabled: true,
endpoint: {
path: '/auth/refresh',
method: 'post',
},
token: {
signInResponseRefreshTokenPointer: '/refreshToken',
refreshResponseTokenPointer: '/accessToken',
refreshResponseRefreshTokenPointer: '/refreshToken', // For token rotation
refreshRequestTokenPointer: 'refreshToken', // Key in request body
cookieName: 'auth.refresh_token',
maxAgeInSeconds: 604800, // 7 days
sameSiteAttribute: 'lax',
secureCookieAttribute: undefined,
httpOnlyCookieAttribute: false,
},
refreshOnlyToken: true,
refreshBeforeExpiryInSeconds: 300, // Refresh 5 min before expiry
},
}How it works:
- When user logs in, both access and refresh tokens are stored
- A timer is set to refresh the token 5 minutes before expiry
- When the timer fires, the module calls the refresh endpoint
- New tokens are stored and a new timer is set
- This continues seamlessly as long as the user is active
Two-Factor Authentication Configuration
Enable 2FA support for your application.
auth: {
twoFactor: {
enabled: true,
endpoints: {
verify: {
path: '/auth/2fa/verify',
method: 'post',
body: {
userId: 'userId',
code: 'code',
},
},
resend: {
path: '/auth/2fa/resend',
method: 'post',
body: {
userId: 'userId',
},
},
// Optional: Enable/disable 2FA for user account
enable: {
path: '/auth/2fa/enable',
method: 'post',
body: {
password: 'password',
},
},
disable: {
path: '/auth/2fa/disable',
method: 'post',
body: {
password: 'password',
code: 'code',
},
},
},
// Response extraction from login when 2FA is required
response: {
requiresTwoFactorPointer: '/requiresTwoFactor',
userIdPointer: '/userId',
emailPointer: '/email', // Optional: masked email
expiresInPointer: '/expiresIn', // Optional: code expiry time
},
// Response extraction from verify endpoint
verifyResponse: {
tokenPointer: '/accessToken',
refreshTokenPointer: '/refreshToken',
},
// Response extraction from resend endpoint
resendResponse: {
messagePointer: '/message',
cooldownPointer: '/cooldownSeconds',
},
// UI Configuration
verifyPage: '/auth/verify-2fa',
codeExpirationInSeconds: 300, // 5 minutes
resendCooldownInSeconds: 60, // 1 minute cooldown between resends
},
}Expected API Response for Login (when 2FA required):
{
"requiresTwoFactor": true,
"userId": "user-123",
"email": "u***@example.com",
"expiresIn": 300
}Password Reset Configuration
Enable password reset functionality.
auth: {
passwordReset: {
enabled: true,
endpoints: {
request: {
path: '/auth/forgot-password',
method: 'post',
body: {
email: 'email',
},
},
reset: {
path: '/auth/reset-password',
method: 'post',
body: {
token: 'token',
password: 'password',
passwordConfirm: 'passwordConfirm',
},
},
},
// Response extraction
requestResponse: {
messagePointer: '/message',
},
resetResponse: {
messagePointer: '/message',
// Optional: Auto-login after password reset
tokenPointer: '/accessToken',
refreshTokenPointer: '/refreshToken',
},
// UI Configuration
requestPage: '/auth/forgot-password',
resetPage: '/auth/reset-password',
},
}Email Verification Configuration
Enable email verification functionality.
auth: {
emailVerification: {
enabled: true,
endpoints: {
verify: {
path: '/auth/verify-email',
method: 'post',
body: {
token: 'token',
},
},
resend: {
path: '/auth/resend-verification',
method: 'post',
body: {
email: 'email',
},
},
},
// Response extraction
verifyResponse: {
messagePointer: '/message',
verifiedPointer: '/verified',
},
resendResponse: {
messagePointer: '/message',
},
// UI Configuration
verifyPage: '/auth/verify-email',
},
}Session Configuration
Configure session data handling.
auth: {
session: {
// Define expected session data types (for TypeScript)
dataType: {
id: 'string',
email: 'string',
firstName: 'string',
lastName: 'string',
role: 'string',
twoFactorEnabled: 'boolean',
},
// JSON pointer if session data is nested in response
dataResponsePointer: '/user', // For { user: { id, email, ... } }
},
}Pages & Middleware Configuration
Configure authentication pages and route protection.
auth: {
pages: {
login: '/auth/login', // Redirect here when not authenticated
home: '/dashboard', // Redirect here after login
},
globalMiddleware: true, // Apply auth middleware to all routes
publicRoutes: [
'/', // Exact match
'/about',
'/auth/*', // Wildcard: all /auth/* routes
'^/blog/\\d+$', // Regex: /blog/123 but not /blog/abc
],
}Composables
useAuth
The main authentication composable. Always available.
const {
// Reactive State
token, // Ref<string | null> - Current access token
refreshToken, // Ref<string | null> - Current refresh token
session, // Ref<Session | null> - User session data
isAuthenticated, // ComputedRef<boolean> - Is user logged in
isLoading, // ComputedRef<boolean> - Is operation in progress
error, // ComputedRef<string | null> - Last error message
// Actions
signIn, // (credentials) => Promise<SignInResult>
signOut, // () => Promise<void>
getSession, // () => Promise<Session | null>
refresh, // () => Promise<boolean>
completeSignIn, // (accessToken, refreshToken?) => Promise<void>
clearAuth, // () => void
} = useAuth();signIn(credentials)
Authenticate user with credentials.
interface SignInResult {
requiresTwoFactor: boolean;
userId?: string;
email?: string;
expiresIn?: number;
}
const result = await signIn({
email: '[email protected]',
password: 'password123',
});
if (result.requiresTwoFactor) {
// Redirect to 2FA verification
navigateTo('/auth/verify-2fa');
} else {
// Login successful
navigateTo('/dashboard');
}signOut()
Log out the current user.
await signOut();
// User is logged out, tokens cleared, redirected to logingetSession()
Fetch the current user's session data.
const session = await getSession();
console.log(session?.email, session?.role);refresh()
Manually refresh the access token.
const success = await refresh();
if (!success) {
// Refresh failed, user needs to login again
}completeSignIn(accessToken, refreshToken?)
Complete authentication after 2FA verification. Usually called internally by use2FA.
await completeSignIn('new-access-token', 'new-refresh-token');clearAuth()
Clear all authentication state without calling logout endpoint.
clearAuth();use2FA
Two-factor authentication composable. Available when twoFactor.enabled: true.
const {
// Reactive State
pending2FA, // Readonly<Ref<Pending2FA | null>> - Current 2FA state
is2FAExpired, // ComputedRef<boolean> - Has the code expired
timeRemaining, // ComputedRef<number> - Seconds until expiry
canResend, // ComputedRef<boolean> - Can resend code
resendCooldown, // Readonly<Ref<number>> - Seconds until can resend
isLoading, // ComputedRef<boolean>
error, // ComputedRef<string | null>
// Actions
initiate2FA, // (userId, email?, expiresIn?) => void
verify2FA, // (code) => Promise<void>
resend2FA, // () => Promise<{ message?, cooldownSeconds? }>
cancel2FA, // () => void
} = use2FA();Pending2FA Interface
interface Pending2FA {
userId: string;
email?: string;
expiresAt: number; // Timestamp when code expires
}initiate2FA(userId, email?, expiresIn?)
Start the 2FA verification process. Called automatically by signIn when 2FA is required.
initiate2FA('user-123', 'u***@example.com', 300);verify2FA(code)
Verify the 2FA code and complete authentication.
try {
await verify2FA('123456');
// User is now fully authenticated
navigateTo('/dashboard');
} catch (err) {
// Invalid code, error is set automatically
}resend2FA()
Request a new 2FA code.
if (canResend.value) {
const { message, cooldownSeconds } = await resend2FA();
// New code sent, cooldown timer started
}cancel2FA()
Cancel the 2FA process and clear state.
cancel2FA();
navigateTo('/auth/login');usePasswordReset
Password reset composable. Available when passwordReset.enabled: true.
const {
// Reactive State
isLoading, // Readonly<Ref<boolean>>
error, // Readonly<Ref<string | null>>
emailSent, // Readonly<Ref<boolean>> - Reset email sent successfully
// Actions
requestReset, // (email) => Promise<{ message? }>
resetPassword, // (token, password, passwordConfirm?) => Promise<{ message? }>
clearState, // () => void
} = usePasswordReset();requestReset(email)
Request a password reset email.
try {
const { message } = await requestReset('[email protected]');
// emailSent.value is now true
console.log(message); // "Check your email for reset instructions"
} catch (err) {
// Error handled automatically
}resetPassword(token, password, passwordConfirm?)
Reset the password using the token from the email link.
const route = useRoute();
const token = route.query.token as string;
try {
await resetPassword(token, 'newPassword123', 'newPassword123');
// Password reset successful
// If auto-login is configured, user is now authenticated
navigateTo('/dashboard');
} catch (err) {
// Invalid token or password requirements not met
}useEmailVerification
Email verification composable. Available when emailVerification.enabled: true.
const {
// Reactive State
isLoading, // Readonly<Ref<boolean>>
error, // Readonly<Ref<string | null>>
isVerified, // Readonly<Ref<boolean>>
// Actions
verifyEmail, // (token) => Promise<{ message?, verified? }>
resendVerification, // (email?) => Promise<{ message? }>
clearState, // () => void
} = useEmailVerification();verifyEmail(token)
Verify the email using the token from the verification link.
const route = useRoute();
const token = route.query.token as string;
try {
const { message, verified } = await verifyEmail(token);
// isVerified.value is now true
console.log(message); // "Email verified successfully"
} catch (err) {
// Invalid or expired token
}resendVerification(email?)
Resend the verification email.
try {
const { message } = await resendVerification('[email protected]');
console.log(message); // "Verification email sent"
} catch (err) {
// Error handled automatically
}Route Protection
The module includes middleware for protecting routes.
Global Middleware
Apply to all routes automatically:
// nuxt.config.ts
auth: {
globalMiddleware: true,
publicRoutes: [
'/',
'/auth/*',
'/about',
'/pricing',
],
}Per-Page Middleware
Apply to specific pages:
// nuxt.config.ts
auth: {
globalMiddleware: false, // Disable global
}<!-- pages/dashboard.vue -->
<script setup>
definePageMeta({
middleware: 'auth',
});
</script>Public Route Patterns
The module supports three types of route matching:
| Pattern | Example | Matches |
|---------|---------|---------|
| Exact | /about | Only /about |
| Wildcard | /auth/* | /auth/login, /auth/register, etc. |
| Regex | ^/blog/\d+$ | /blog/123 but not /blog/abc |
publicRoutes: [
'/', // Exact: only root
'/about', // Exact: only /about
'/auth/*', // Wildcard: all auth routes
'/api/*', // Wildcard: all API routes
'^/posts/\\d+$', // Regex: /posts/123
'^/users/[a-z]+/profile$', // Regex: /users/john/profile
],Redirect Behavior
| Scenario | Action |
|----------|--------|
| Unauthenticated → Protected | Redirect to login with ?redirect=/original-path |
| Authenticated → Auth page | Redirect to home |
| Any → Public route | Allow access |
Complete Examples
Login Page
<!-- pages/auth/login.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'auth',
});
const { signIn, isLoading, error } = useAuth();
const { initiate2FA } = use2FA();
const route = useRoute();
const email = ref('');
const password = ref('');
async function handleSubmit() {
try {
const result = await signIn({
email: email.value,
password: password.value,
});
if (result.requiresTwoFactor) {
// Store 2FA state and redirect
initiate2FA(result.userId!, result.email, result.expiresIn);
navigateTo('/auth/verify-2fa');
} else {
// Successful login - redirect to original destination or home
const redirect = route.query.redirect as string;
navigateTo(redirect || '/dashboard');
}
} catch (err) {
// Error is automatically set in error ref
}
}
</script>
<template>
<div class="login-page">
<h1>Sign In</h1>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
placeholder="[email protected]"
required
autocomplete="email"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
placeholder="••••••••"
required
autocomplete="current-password"
/>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button type="submit" :disabled="isLoading">
{{ isLoading ? 'Signing in...' : 'Sign In' }}
</button>
<div class="links">
<NuxtLink to="/auth/forgot-password">Forgot password?</NuxtLink>
<NuxtLink to="/auth/register">Create account</NuxtLink>
</div>
</form>
</div>
</template>2FA Verification Page
<!-- pages/auth/verify-2fa.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'auth',
});
const {
pending2FA,
verify2FA,
resend2FA,
cancel2FA,
is2FAExpired,
timeRemaining,
canResend,
resendCooldown,
isLoading,
error,
} = use2FA();
const code = ref('');
const resendMessage = ref('');
// Redirect if no pending 2FA
onMounted(() => {
if (!pending2FA.value) {
navigateTo('/auth/login');
}
});
// Format time remaining
const formattedTime = computed(() => {
const minutes = Math.floor(timeRemaining.value / 60);
const seconds = timeRemaining.value % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
});
async function handleVerify() {
try {
await verify2FA(code.value);
navigateTo('/dashboard');
} catch (err) {
code.value = ''; // Clear code on error
}
}
async function handleResend() {
try {
const { message } = await resend2FA();
resendMessage.value = message || 'Code sent!';
setTimeout(() => {
resendMessage.value = '';
}, 3000);
} catch (err) {
// Error handled automatically
}
}
function handleCancel() {
cancel2FA();
navigateTo('/auth/login');
}
</script>
<template>
<div class="verify-2fa-page">
<h1>Two-Factor Authentication</h1>
<div v-if="pending2FA" class="content">
<p>
Enter the verification code sent to
<strong>{{ pending2FA.email || 'your email' }}</strong>
</p>
<div v-if="is2FAExpired" class="expired-message">
<p>Your code has expired.</p>
<button @click="handleResend" :disabled="!canResend || isLoading">
Request New Code
</button>
</div>
<form v-else @submit.prevent="handleVerify">
<div class="form-group">
<label for="code">Verification Code</label>
<input
id="code"
v-model="code"
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
placeholder="000000"
required
autocomplete="one-time-code"
/>
</div>
<p class="timer">Code expires in {{ formattedTime }}</p>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="resendMessage" class="success-message">
{{ resendMessage }}
</div>
<button type="submit" :disabled="isLoading || code.length < 6">
{{ isLoading ? 'Verifying...' : 'Verify' }}
</button>
<div class="actions">
<button
type="button"
@click="handleResend"
:disabled="!canResend || isLoading"
class="link-button"
>
{{ canResend ? 'Resend code' : `Resend in ${resendCooldown}s` }}
</button>
<button
type="button"
@click="handleCancel"
class="link-button"
>
Cancel
</button>
</div>
</form>
</div>
<div v-else class="no-session">
<p>No verification session found.</p>
<NuxtLink to="/auth/login">Return to Login</NuxtLink>
</div>
</div>
</template>Forgot Password Page
<!-- pages/auth/forgot-password.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'auth',
});
const { requestReset, emailSent, isLoading, error, clearState } = usePasswordReset();
const email = ref('');
async function handleSubmit() {
try {
await requestReset(email.value);
// emailSent.value is now true
} catch (err) {
// Error handled automatically
}
}
// Clear state when leaving page
onUnmounted(() => {
clearState();
});
</script>
<template>
<div class="forgot-password-page">
<h1>Forgot Password</h1>
<div v-if="emailSent" class="success-state">
<div class="icon">✓</div>
<h2>Check your email</h2>
<p>
We've sent a password reset link to
<strong>{{ email }}</strong>
</p>
<p class="note">
Didn't receive the email? Check your spam folder or
<button @click="emailSent = false" class="link-button">
try again
</button>
</p>
<NuxtLink to="/auth/login" class="back-link">
Back to Login
</NuxtLink>
</div>
<form v-else @submit.prevent="handleSubmit">
<p>Enter your email address and we'll send you a link to reset your password.</p>
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
placeholder="[email protected]"
required
autocomplete="email"
/>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button type="submit" :disabled="isLoading">
{{ isLoading ? 'Sending...' : 'Send Reset Link' }}
</button>
<NuxtLink to="/auth/login" class="back-link">
Back to Login
</NuxtLink>
</form>
</div>
</template>Reset Password Page
<!-- pages/auth/reset-password.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'auth',
});
const { resetPassword, isLoading, error, clearState } = usePasswordReset();
const route = useRoute();
const password = ref('');
const passwordConfirm = ref('');
const success = ref(false);
const token = computed(() => route.query.token as string);
// Redirect if no token
onMounted(() => {
if (!token.value) {
navigateTo('/auth/forgot-password');
}
});
async function handleSubmit() {
if (password.value !== passwordConfirm.value) {
return;
}
try {
await resetPassword(token.value, password.value, passwordConfirm.value);
success.value = true;
// If auto-login is configured, redirect to dashboard
// Otherwise, redirect to login
setTimeout(() => {
navigateTo('/auth/login?reset=success');
}, 2000);
} catch (err) {
// Error handled automatically
}
}
const passwordsMatch = computed(() => {
return password.value === passwordConfirm.value;
});
onUnmounted(() => {
clearState();
});
</script>
<template>
<div class="reset-password-page">
<h1>Reset Password</h1>
<div v-if="success" class="success-state">
<div class="icon">✓</div>
<h2>Password Reset!</h2>
<p>Your password has been successfully reset.</p>
<p>Redirecting to login...</p>
</div>
<div v-else-if="!token" class="no-token">
<p>Invalid or missing reset token.</p>
<NuxtLink to="/auth/forgot-password">
Request a new reset link
</NuxtLink>
</div>
<form v-else @submit.prevent="handleSubmit">
<div class="form-group">
<label for="password">New Password</label>
<input
id="password"
v-model="password"
type="password"
placeholder="••••••••"
required
minlength="8"
autocomplete="new-password"
/>
</div>
<div class="form-group">
<label for="passwordConfirm">Confirm Password</label>
<input
id="passwordConfirm"
v-model="passwordConfirm"
type="password"
placeholder="••••••••"
required
autocomplete="new-password"
/>
<p v-if="passwordConfirm && !passwordsMatch" class="field-error">
Passwords do not match
</p>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button type="submit" :disabled="isLoading || !passwordsMatch">
{{ isLoading ? 'Resetting...' : 'Reset Password' }}
</button>
</form>
</div>
</template>Email Verification Page
<!-- pages/auth/verify-email.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'auth',
});
const { verifyEmail, resendVerification, isVerified, isLoading, error, clearState } = useEmailVerification();
const { session } = useAuth();
const route = useRoute();
const resendEmail = ref('');
const resendSent = ref(false);
const token = computed(() => route.query.token as string);
// Auto-verify on page load
onMounted(async () => {
if (token.value) {
try {
await verifyEmail(token.value);
} catch (err) {
// Error handled automatically
}
}
});
async function handleResend() {
const email = resendEmail.value || session.value?.email;
if (!email) return;
try {
await resendVerification(email);
resendSent.value = true;
} catch (err) {
// Error handled automatically
}
}
onUnmounted(() => {
clearState();
});
</script>
<template>
<div class="verify-email-page">
<h1>Email Verification</h1>
<div v-if="isLoading" class="loading-state">
<div class="spinner"></div>
<p>Verifying your email...</p>
</div>
<div v-else-if="isVerified" class="success-state">
<div class="icon">✓</div>
<h2>Email Verified!</h2>
<p>Your email has been successfully verified.</p>
<NuxtLink to="/dashboard" class="button">
Continue to Dashboard
</NuxtLink>
</div>
<div v-else-if="error" class="error-state">
<div class="icon">✗</div>
<h2>Verification Failed</h2>
<p>{{ error }}</p>
<div v-if="resendSent" class="resend-success">
<p>A new verification link has been sent to your email.</p>
</div>
<div v-else class="resend-form">
<p>Enter your email to receive a new verification link:</p>
<div class="form-group">
<input
v-model="resendEmail"
type="email"
placeholder="[email protected]"
:value="session?.email"
/>
<button @click="handleResend" :disabled="isLoading">
Resend Link
</button>
</div>
</div>
<NuxtLink to="/auth/login" class="back-link">
Back to Login
</NuxtLink>
</div>
<div v-else class="no-token">
<p>No verification token found.</p>
<NuxtLink to="/">Go Home</NuxtLink>
</div>
</div>
</template>Protected Dashboard
<!-- pages/dashboard.vue -->
<script setup lang="ts">
// This page is protected by the auth middleware
const { session, signOut, isLoading } = useAuth();
async function handleLogout() {
await signOut();
// User is automatically redirected to login
}
</script>
<template>
<div class="dashboard">
<header>
<h1>Dashboard</h1>
<div class="user-info">
<span>{{ session?.email }}</span>
<button @click="handleLogout" :disabled="isLoading">
{{ isLoading ? 'Logging out...' : 'Logout' }}
</button>
</div>
</header>
<main>
<div class="welcome-card">
<h2>Welcome, {{ session?.firstName || 'User' }}!</h2>
<p>You are logged in as {{ session?.role || 'member' }}.</p>
</div>
<div class="user-details">
<h3>Your Profile</h3>
<dl>
<dt>Email</dt>
<dd>{{ session?.email }}</dd>
<dt>User ID</dt>
<dd>{{ session?.id }}</dd>
<dt>2FA Enabled</dt>
<dd>{{ session?.twoFactorEnabled ? 'Yes' : 'No' }}</dd>
</dl>
</div>
</main>
</div>
</template>Practical Recipes
Custom API Headers
Add custom headers to all auth requests (e.g., tenant ID, API version):
// composables/useAuthFetch.ts
export function useAuthFetch() {
const { token } = useAuth();
const config = useRuntimeConfig();
return $fetch.create({
baseURL: config.public.auth.baseURL,
onRequest({ options }) {
// Add auth header
if (token.value) {
options.headers = {
...options.headers,
Authorization: `Bearer ${token.value}`,
};
}
// Add custom headers
options.headers = {
...options.headers,
'X-Tenant-ID': 'my-tenant',
'X-API-Version': '2024-01',
};
},
});
}Handle Token Expiration
Create a global error handler for expired tokens:
// plugins/auth-error-handler.client.ts
export default defineNuxtPlugin(() => {
const { clearAuth } = useAuth();
// Global fetch interceptor
const originalFetch = globalThis.$fetch;
globalThis.$fetch = async (request, options) => {
try {
return await originalFetch(request, options);
} catch (error: any) {
// Handle 401 Unauthorized
if (error?.response?.status === 401) {
clearAuth();
navigateTo('/auth/login?expired=true');
}
throw error;
}
};
});<!-- pages/auth/login.vue -->
<script setup>
const route = useRoute();
const showExpiredMessage = computed(() => route.query.expired === 'true');
</script>
<template>
<div v-if="showExpiredMessage" class="warning">
Your session has expired. Please sign in again.
</div>
<!-- login form -->
</template>Persist User Preference
Remember user's email for faster login:
<!-- pages/auth/login.vue -->
<script setup>
const { signIn, isLoading, error } = useAuth();
// Persist email in localStorage
const rememberedEmail = useCookie('remembered_email', {
maxAge: 60 * 60 * 24 * 30, // 30 days
});
const email = ref(rememberedEmail.value || '');
const password = ref('');
const rememberMe = ref(!!rememberedEmail.value);
async function handleSubmit() {
// Save or clear remembered email
if (rememberMe.value) {
rememberedEmail.value = email.value;
} else {
rememberedEmail.value = null;
}
const result = await signIn({ email: email.value, password: password.value });
// ...
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="email" type="email" placeholder="Email" />
<input v-model="password" type="password" placeholder="Password" />
<label>
<input v-model="rememberMe" type="checkbox" />
Remember my email
</label>
<button :disabled="isLoading">Sign In</button>
</form>
</template>Multi-tenant Authentication
Handle authentication for multi-tenant SaaS applications:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@dommidev10/nuxt-jwt-auth'],
auth: {
// Dynamic baseURL based on tenant
baseURL: '', // Set dynamically
endpoints: {
signIn: {
path: '/auth/login',
method: 'post',
body: {
email: 'email',
password: 'password',
// Include tenant in login
tenantId: 'tenantId',
},
},
},
},
});<!-- pages/auth/login.vue -->
<script setup>
const { signIn } = useAuth();
const route = useRoute();
// Get tenant from subdomain or route
const tenant = computed(() => {
// subdomain: acme.app.com → 'acme'
const host = window.location.host;
const subdomain = host.split('.')[0];
return subdomain !== 'app' ? subdomain : route.query.tenant;
});
async function handleLogin() {
await signIn({
email: email.value,
password: password.value,
tenantId: tenant.value,
});
}
</script>Protect API Routes
Use the token in server API routes:
// server/api/protected-data.get.ts
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
// Get token from request cookies
const token = getCookie(event, 'auth.token');
if (!token) {
throw createError({
statusCode: 401,
message: 'Unauthorized',
});
}
// Forward request to backend with token
const data = await $fetch(`${config.apiBaseURL}/protected-endpoint`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
return data;
});Role-Based Access Control
Implement role-based page protection:
// middleware/admin.ts
export default defineNuxtRouteMiddleware(() => {
const { session, isAuthenticated } = useAuth();
if (!isAuthenticated.value) {
return navigateTo('/auth/login');
}
if (session.value?.role !== 'admin') {
return navigateTo('/dashboard?error=unauthorized');
}
});<!-- pages/admin/users.vue -->
<script setup>
definePageMeta({
middleware: ['auth', 'admin'], // Apply both middlewares
});
</script>Conditional UI Based on Auth State
Show different navigation based on authentication:
<!-- components/AppHeader.vue -->
<script setup>
const { session, isAuthenticated, signOut } = useAuth();
</script>
<template>
<header>
<nav>
<NuxtLink to="/">Home</NuxtLink>
<template v-if="isAuthenticated">
<NuxtLink to="/dashboard">Dashboard</NuxtLink>
<!-- Admin-only link -->
<NuxtLink v-if="session?.role === 'admin'" to="/admin">
Admin Panel
</NuxtLink>
<div class="user-menu">
<span>{{ session?.email }}</span>
<button @click="signOut">Logout</button>
</div>
</template>
<template v-else>
<NuxtLink to="/auth/login">Login</NuxtLink>
<NuxtLink to="/auth/register">Register</NuxtLink>
</template>
</nav>
</header>
</template>Auto-Logout on Inactivity
Implement automatic logout after period of inactivity:
// plugins/auto-logout.client.ts
export default defineNuxtPlugin(() => {
const { isAuthenticated, signOut } = useAuth();
const INACTIVITY_TIMEOUT = 30 * 60 * 1000; // 30 minutes
let timeoutId: ReturnType<typeof setTimeout>;
function resetTimer() {
clearTimeout(timeoutId);
if (isAuthenticated.value) {
timeoutId = setTimeout(async () => {
await signOut();
navigateTo('/auth/login?reason=inactivity');
}, INACTIVITY_TIMEOUT);
}
}
// Reset timer on user activity
if (typeof window !== 'undefined') {
['mousedown', 'keydown', 'touchstart', 'scroll'].forEach((event) => {
window.addEventListener(event, resetTimer, { passive: true });
});
// Initial timer
resetTimer();
// Watch for auth state changes
watch(isAuthenticated, (authenticated) => {
if (authenticated) {
resetTimer();
} else {
clearTimeout(timeoutId);
}
});
}
});JSON Pointers
The module uses JSON pointers (RFC 6901) to extract values from API responses. This allows you to work with any API response structure.
Basic Usage
// API Response:
{ "accessToken": "eyJ..." }
// Pointer:
signInResponseTokenPointer: '/accessToken'
// Extracts: "eyJ..."Nested Paths
// API Response:
{
"data": {
"auth": {
"token": "eyJ..."
}
}
}
// Pointer:
signInResponseTokenPointer: '/data/auth/token'
// Extracts: "eyJ..."Root Extraction
// API Response for getSession:
{ "id": "123", "email": "[email protected]" }
// Pointer:
session: {
dataResponsePointer: '' // or '/'
}
// Returns entire response as sessionNested Session
// API Response for getSession:
{
"success": true,
"user": { "id": "123", "email": "[email protected]" }
}
// Pointer:
session: {
dataResponsePointer: '/user'
}
// Returns only the user object as sessionBody Mapping
Body mapping allows you to adapt the module's field names to your API's expected field names.
Standard Mapping
// Module sends:
signIn({ email: '[email protected]', password: 'secret' })
// With default config:
body: { email: 'email', password: 'password' }
// API receives:
{ "email": "[email protected]", "password": "secret" }Custom Field Names
// Module sends:
signIn({ email: '[email protected]', password: 'secret' })
// With custom config:
body: { email: 'username', password: 'pwd' }
// API receives:
{ "username": "[email protected]", "pwd": "secret" }2FA Example
// Module sends internally:
{ userId: 'user-123', code: '123456' }
// With config:
body: { userId: 'user_id', code: 'otp_code' }
// API receives:
{ "user_id": "user-123", "otp_code": "123456" }Backend Integration
The module is backend-agnostic. Here's what your API needs to implement:
Required Endpoints
POST /auth/login
Request:
{
"email": "[email protected]",
"password": "password123"
}Success Response (no 2FA):
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}Success Response (2FA required):
{
"requiresTwoFactor": true,
"userId": "user-123",
"email": "u***@example.com",
"expiresIn": 300
}POST /auth/logout
Request: Empty or with refresh token
Response: 200 OK
GET /auth/me
Headers: Authorization: Bearer <accessToken>
Response:
{
"id": "user-123",
"email": "[email protected]",
"firstName": "John",
"lastName": "Doe",
"role": "admin",
"twoFactorEnabled": true
}Optional Endpoints
POST /auth/refresh
Request:
{
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}Response:
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}POST /auth/2fa/verify
Request:
{
"userId": "user-123",
"code": "123456"
}Response:
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}POST /auth/2fa/resend
Request:
{
"userId": "user-123"
}Response:
{
"message": "Code sent successfully",
"cooldownSeconds": 60
}POST /auth/forgot-password
Request:
{
"email": "[email protected]"
}Response:
{
"message": "Reset email sent"
}POST /auth/reset-password
Request:
{
"token": "reset-token-from-email",
"password": "newPassword123",
"passwordConfirm": "newPassword123"
}Response:
{
"message": "Password reset successfully"
}POST /auth/verify-email
Request:
{
"token": "verification-token-from-email"
}Response:
{
"message": "Email verified",
"verified": true
}TypeScript
The module includes full TypeScript support.
Type Augmentation
Add session types for better autocompletion:
// types/auth.d.ts
declare module '@dommidev10/nuxt-jwt-auth' {
interface Session {
id: string;
email: string;
firstName: string;
lastName: string;
role: 'admin' | 'user' | 'guest';
twoFactorEnabled: boolean;
organizationId?: string;
}
}
export {};Using Types
// In your components
const { session } = useAuth();
// session is typed as Session | null
console.log(session.value?.role); // TypeScript knows about roleConfiguration Types
import type { AuthModuleOptions } from '@dommidev10/nuxt-jwt-auth';
const config: AuthModuleOptions = {
baseURL: 'https://api.example.com',
// Full autocompletion available
};Troubleshooting
Common Issues
"Auth is not defined" or "useAuth is not a function"
Make sure the module is properly added to your nuxt.config.ts:
modules: ['@dommidev10/nuxt-jwt-auth'],Token not being sent with requests
Ensure your API base URL matches and the token configuration is correct:
auth: {
baseURL: 'https://api.example.com', // Must match your API
token: {
headerName: 'Authorization',
type: 'Bearer',
},
}2FA/Password Reset/Email Verification composables not available
These composables are only registered when their feature is enabled:
auth: {
twoFactor: { enabled: true }, // Enables use2FA()
passwordReset: { enabled: true }, // Enables usePasswordReset()
emailVerification: { enabled: true }, // Enables useEmailVerification()
}Refresh token not working
Check your refresh configuration:
auth: {
refresh: {
enabled: true, // Must be true
endpoint: { path: '/auth/refresh', method: 'post' },
token: {
signInResponseRefreshTokenPointer: '/refreshToken',
refreshResponseTokenPointer: '/accessToken',
},
},
}Cookies not being set
For production with HTTPS:
auth: {
token: {
secureCookieAttribute: true,
sameSiteAttribute: 'lax',
},
}Debug Mode
Enable debug mode to see detailed logs:
auth: {
debug: true,
}This will log:
- API requests and responses
- Token refresh scheduling
- Middleware decisions
- State changes
Development
Setup
# Clone the repository
git clone https://github.com/dommidev10/nuxt-jwt-auth.git
cd nuxt-jwt-auth
# Install dependencies
pnpm install
# Build the module
pnpm build
# Run the playground
cd playground && pnpm devScripts
pnpm build # Build the module
pnpm typecheck # Run TypeScript checks
pnpm lint # Run linter
pnpm test # Run testsProject Structure
nuxt-jwt-auth/
├── src/
│ ├── module.ts # Module entry point
│ ├── types.ts # TypeScript definitions
│ └── runtime/
│ ├── composables/
│ │ ├── useAuth.ts # Main auth composable
│ │ ├── use2FA.ts # 2FA composable
│ │ ├── usePasswordReset.ts # Password reset composable
│ │ └── useEmailVerification.ts
│ ├── middleware/
│ │ └── auth.ts # Route protection
│ ├── plugins/
│ │ └── auth.client.ts # Client-side auto-refresh
│ └── utils/
│ └── extract-value.ts # JSON pointer utilities
├── playground/ # Test application
└── dist/ # Built moduleLicense
MIT License
Copyright (c) 2024 DommiDev10
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
