Christoph Heike - Author

Christoph Heike

Blog Post

How to implement Server Side JWT Auth/Proxy calls with Nuxt 3 / Vue 3 and HttpOnly Cookies when using an External API

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.

Note: Before we start, there are some great libraries out there like nuxt/auth or nuxt-session that provide fully-featured solutions for Auth or Session respectively. But, if you want something more simple and have another API, keep on reading.

Authenticating against a third party API with Secure Cookies

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.

This post is right for you if:

  • 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)

Flash of Protected Pages

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.

Middleware and Server Side User Authentication gives you a good feeling

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.)

HttpOnly Cookies means more security

These cannot be accessed by any client side JavaScript. You can verify if a Cookie is HttpOnly this within your Browser's development tools.

Step 1 - Let's begin by writing our "auth" Nuxt Plugin.

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
            }
        }
    }

The Login Page

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>

The Server Side Routes

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;
})

Server Side Helpers

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.

Final Piece: The Nuxt Server Side Middleware / "Proxy"

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
        }
    });
})
We'll Call You Back