Christoph Heike
Blog Post
If you are building a Nuxt 3 application that authenticates against an external API, you want to make sure you have a proper and secure authentication workflow. In my case, I have an external API that takes care of the authentication with a JWT workflow. I didn't want to implement a full authentication system into my Nuxt application, but I still wanted to be able to securely identify a user on the server side with httpOnly cookies. Here's how I implemented it for this special case.
Setup
Before we start, here's my setup. I have an external REST API that handles all the back-end tasks, and we want our "front-end" application (Nuxt 3) to authenticate against this external API in a secure way.
The Problem
Working with something like Nuxt 3, which enables SSR with the same language, even inside the same files and components, might be very perplexing if you are coming from a server-side web programming language like PHP. You might get confused about what is happening on the server, what is happening on the client and think about what is secure and what isn't.
You are using Nuxt as a companion to another (internal or external) API that you use to authenticate users
You want a Server Side authentication workflow
You want to keep it simple and don't want use external packages like nuxt/auth or nuxt-session
You want cookies to be Server Side managed and HttpOnly (can't be accessed by JavaScript, making XSS attacks more difficult)
We don't want to rely on the client to validate the user's token and fetch the user's data. This means we would have to somehow implement a Splash Screen or Loading Spinner to prevent the user from seeing content he is not supposed to see - Or in the worst case, have some potential data leaks where secure data would reach the client.
You just know it's more secure if it doesn't rely on any client-side JS - Also, you are able to implement any middleware on the server (for example, check for an active billing subscription etc.)
These cannot be accessed by any client side JavaScript. You can verify if a Cookie is HttpOnly this within your Browser's development tools.
I've decided to go with a Nuxt Plugin - Simply a file in the /plugins folder - To encapsulate most of the authentication logic.
// /plugins/1.auth.js
import {useServerSideLogout} from "~/server/utils/auth.js";
export default defineNuxtPlugin(async (nuxtApp) => {
// This state will hold our user data if logged in. Will be fetched on the server and stored in the Nuxt payload
const userData = useState('userData', () => null)
const isUserLoggedIn = computed(() => !!userData.value?.email)
const jwtCookie = useCookie(useRuntimeConfig().public.cookieName, {
sameSite: 'strict',
httpOnly: true,
secure: true,
})
const jwtRefreshCookie = useCookie(useRuntimeConfig().public.cookieName + '-refresh', {
sameSite: 'strict',
httpOnly: true,
secure: true,
})
const login = (username, password) => {
return $fetch('/api/auth/login', {
method: 'post',
body: {
username,
password
},
}).then((response) => {
if (response) {
return fetchUserData();
}
});
}
const logout = async () => {
if (process.server) {
jwtCookie.value = null;
jwtRefreshCookie.value = null;
userData.value = null;
} else {
const response = await useServerSideLogout();
userData.value = null;
await navigateTo('/login')
}
}
const fetchUserData = () => {
return $fetch('/api/user', {
method: 'GET',
onResponseError(context) {
userData.value = null;
// Error? Let's log the user out and remove all cookies
useServerSideLogout();
},
onResponse({request, response, options}) {
userData.value = response._data;
}
});
}
addRouteMiddleware(
"auth",
async (to, from) => {
// Initial, SSR page load -> Fetch user data and store into Nuxt Payload (automatic)
if (process.server) {
if (jwtCookie?.value?.length > 0) {
const onError = (err) => {
return '/login';
}
await fetchUserData().catch(onError);
}
}
if (isUserLoggedIn.value === true) {
// Great, the user is logged in
if (to.path === '/login') {
return "/home";
}
}
if (to.path !== '/login' && !isUserLoggedIn.value) {
return "/login";
}
},
{global: true}
);
const currentRoute = useRoute();
if (process.client) {
watch(isUserLoggedIn, async (isUserLoggedIn) => {
if (!isUserLoggedIn && currentRoute.path !== '/login') {
await navigateTo("/login");
}
});
}
return {
provide: {
'auth': {
userData,
isUserLoggedIn,
jwtCookie,
jwtRefreshCookie,
fetchUserData,
logout,
login
}
}
}
})
There's a lot going on here, so let's break it down.
First, we are defining a userData State using Nuxt's useState
Method. This will be shared between the Server and the Client.
Now, most importantly, I'm are using Nuxt's useCookie
method to define the httpOnly cookies.
We are going to have two tokens, a JWT and a JWT Refresh Token.
const jwtCookie = useCookie(useRuntimeConfig().public.cookieName, {
sameSite: 'strict',
httpOnly: true,
secure: true,
})
const jwtRefreshCookie = useCookie(useRuntimeConfig().public.cookieName + '-refresh', {
sameSite: 'strict',
httpOnly: true,
secure: true,
})
Note that, on the client side, these jwtCookie.value
will be undefined, as httpOnly cookies can only be accessed server side.
I made the cookie name configureable through the runtime config - (could potentially be moved to private config):
// nuxt.config.ts
// ...
runtimeConfig: {
public: {
cookieName: 'jwt',
refreshCookieName: 'jwt-refresh',
}
}
Next, we are defining some helper functions. We want the login to POST to our server side login route under /api/auth/login
, and also make sure to fetch the user's data afterwards.
As for the logout, we also want to make sure to use the Server Side /api/auth/logout
Route (we'll define that soon)
const login = (username, password) => {
return $fetch('/api/auth/login', {
method: 'post',
body: {
username,
password
},
}).then((response) => {
if (response) {
return fetchUserData();
}
});
}
const logout = async () => {
if (process.server) {
jwtCookie.value = null;
jwtRefreshCookie.value = null;
userData.value = null;
} else {
const response = await useServerSideLogout();
userData.value = null;
await navigateTo('/login')
}
}
const fetchUserData = () => {
return $fetch('/api/user', {
method: 'GET',
onResponseError(context) {
userData.value = null;
// Error? Let's log the user out and remove all cookies
useServerSideLogout();
},
onResponse({request, response, options}) {
userData.value = response._data;
}
});
}
Now, let's take care of the middleware.
Let's recap what we want to do:
We want to check for a JWT Cookie
If we are on the server, we want to fetch the User's Data server side. With that, we will know if the token is valid (Back End will only return data if authentication is succesful)
If the user is logged in, we want to redirect him to /home
if he is trying to access /login
Finally, let's make all this available in Nuxt with the provide
keyword:
return {
provide: {
'auth': {
userData,
isUserLoggedIn,
jwtCookie,
jwtRefreshCookie,
fetchUserData,
logout,
login
}
}
}
This one's going to be straightforward - Let's write a simple login component.
Before we start, let's also define a composeable helper to retrieve our plugin (we could add more logic here later):
// composeables/auth.js
export const useAuth = () => useNuxtApp().$auth
Now, to the page:
<template>
<div class="min-h-screen flex items-center flex-col justify-center">
<div class="flex flex-col gap-4 w-96">
<h1>
Please log in.
</h1>
<form class="flex flex-col gap-4"
novalidate
@submit.prevent="submit">
<div>
<label for="username">Username</label>
<input type="text" name="username">
</div>
<div>
<label for="username">Password</label>
<input type="password" name="password">
</div>
<button
class="mx-auto"
type="submit"
:loading="loading"
:disabled="!username || !password"
@click="submit">
Login
</button>
</form>
</div>
</div>
</template>
<script setup>
import {useAuth} from "~/composeables/auth.js";
definePageMeta({
title: 'Login'
});
const username = ref();
const password = ref();
const loading = ref(false);
const {userData, login} = useAuth();
const submit = handleSubmit(async (values) => {
loading.value = true;
await login(username, password)
loading.value = false;
});
</script>
Almost there. Let's define our three server side API routes:
/api/auth/login - Log the user in and set the cookies
/api/auth/logout - Logout
/api/auth/refresh - Refresh the JWT
Note that, here, we are using the h3 Framework's (Server Side HTTP Framework Nuxt is using) setCookie
and getCookie
methods.
// Server side login handler
// /server/api/auth/login.js
export default defineEventHandler(async (event) => {
const {apiEndpoint} = useRuntimeConfig()
let url = apiEndpoint + '/token';
const inputBody = await readRawBody(event);
const response = await $fetch(url, {
method: event.node.req.method,
headers: {...event.node.req.headers},
body: inputBody
}).catch(err => {
throw createError(err)
});
if (response?.token && response?.refresh) {
setCookie(event, useRuntimeConfig().public.cookieName, response.token, {
sameSite: 'strict',
httpOnly: true,
secure: true,
});
setCookie(event, useRuntimeConfig().public.cookieName + "-refresh", response.refresh, {
sameSite: 'strict',
httpOnly: true,
secure: true,
});
return true;
} else {
throw createError({statusCode: 500, statusMessage: 'Invalid response from API. ' + JSON.stringify(response)})
}
})
// server/api/auth/logout.js
export default defineEventHandler(async (event) => {
deleteCookie(event, useRuntimeConfig().public.cookieName);
deleteCookie(event, useRuntimeConfig().public.refreshCookieName);
return true;
})
// server/api/auth/refresh.js
export default defineEventHandler(async (event) => {
const {apiEndpoint} = useRuntimeConfig()
let url = apiEndpoint + '/token/refresh';
const response = await $fetch(url, {
method: event.node.req.method,
headers: {...event.node.req.headers},
body: {
token: getCookie(event, useRuntimeConfig().public.refreshCookieName)
}
});
setCookie(event, useRuntimeConfig().public.cookieName, response.token, {
sameSite: 'strict',
httpOnly: true,
secure: true,
});
setCookie(event, useRuntimeConfig().public.refreshCookieName, response.refresh,{
sameSite: 'strict',
httpOnly: true,
secure: true,
});
return true;
})
Let's define some helper functions to refresh our token if it's expired.
Javascript// server/utils/auth.js
import {jwtDecode} from "jwt-decode";
export const useRefreshTokenIfExpired = (token) => {
try {
const decoded = jwtDecode(token);
const now = Math.floor(Date.now() / 1000);
if (decoded?.exp > now) {
return useRefreshToken();
}
} catch (err) {
throw new Error('Unable to decode token')
}
}
export const useRefreshToken = async () => {
// Expired, let's try to refresh
const {data: refreshResponse, error} = await useFetch('/api/auth/refresh', {
method: 'post',
// Actual JWT Refresh token will be sent through the cookies
});
if (error.value) {
throw new Error('Unable to refresh token')
}
// New cookies should be set
return true;
}
export const useServerSideLogout = () => {
return $fetch('/api/auth/logout', {
method: 'post',
});
}
We'll need to install the jwt-decode
package for this to work.
The great thing about Nuxt is that, if we are on the server and it detects a fetch to a Server Side route, it'll actually perform that as a function call instead of a real fetch.
Now that we have everything together, we can start making calls to the external API.
We want to provide an Endpoint under /api/ that will just forward all calls to our external (or internal) API:
// /server/api/[...route].js
// API Middleware
// Works as a proxy to route API calls to the API endpoint
import {useRefreshTokenIfExpired, useServerSideLogout} from "~/server/utils/auth.js";
export default defineEventHandler(async (event) => {
const {apiEndpoint} = useRuntimeConfig()
const jwtCookie = getCookie(event, useRuntimeConfig().public.cookieName);
let authHeaders = {};
if (jwtCookie) {
// If we have a JWT, pass it to the back end API
authHeaders.Authorization = `Bearer ${jwtCookie}`
try {
await useRefreshTokenIfExpired(jwtCookie);
} catch (err) {
// Cannot refresh token, logout
await useServerSideLogout();
return {
redirect: {
statusCode: 302,
destination: '/login',
}
}
}
}
const route = getRouterParam(event, 'route');
let url = apiEndpoint + '/' + route;
let body = null
try {
body = await readRawBody(event);
} catch (e) {
}
return $fetch(url, {
method: event.node.req.method,
headers: {...event.node.req.headers, authHeaders},
body,
onRequestError({request, options, error}) {
// Handle the request errors
},
onResponse({request, response, options}) {
// Process the response data
},
onResponseError({request, response, options}) {
// Handle the response errors
}
});
})