Published: January 31 2023

Vue 3 + Pinia - JWT Authentication with Refresh Tokens Example & Tutorial

Tutorial built with Vue 3.2.45 and Pinia 2.0.29

Other versions available:

In this post we'll go through an example of how to implement JWT authentication with refresh tokens in Vue 3 and Pinia.

Example Vue 3 + Pinia App

The example app is pretty minimal and contains just 2 pages to demonstrate JWT authentication with refresh tokens in Vue 3:

  • Login (/login) - public login page with username and password fields, on submit the page sends a POST request to the API to authenticate user credentials, on success the API returns two tokens:
    • A JWT (JSON Web Token) used to make authenticated requests to secure API routes, the JWT is short-lived and expires after 15 minutes.
    • A Refresh Token used to request a new JWT from the API when the old one expires (a.k.a. to refresh the token).
  • Home (/) - secure home page with a welcome message and a list of users, the users are fetched from a secure API endpoint with the JWT received after successful login.

JWT with Refresh Tokens vs JWT Only

The benefit of using refresh tokens over JWT alone is increased security because it allows you to use short-lived JWT tokens for authentication. JWTs are usually self contained tokens that cannot be revoked and are valid until they expire, so having a long-lived JWT poses a greater security risk if a token is compromised.

Refresh tokens are less likely to be compromised, they can be stored in HTTP Only cookies that are not accessible to client-side javascript which prevents XSS (cross site scripting). Refresh tokens are only sent with requests to generate new JWT tokens, they cannot access other secure routes which prevents them from being used in CSRF (cross site request forgery). Refresh tokens are revokable, if one is compromised it can be revoked on the server so it cannot generate any more JWTs.

The benefit of using JWT alone is simpler code and less complexity, the approach you choose depends on your use case and requirements. For the simplified version of this tutorial that doesn't use refresh tokens see Vue 3 + Pinia - JWT Authentication Tutorial & Example.

Silent refresh of JWT auth tokens

On successful login the Vue app starts a countdown timer to automatically refresh the JWT one minute before it expires, this is known as silent refresh since it happens in the background. The timer restarts after each silent refresh so the JWT is always valid.

Keep logged in between browser sessions

To keep the user logged in between browser sessions the Vue app automatically attempts to request a new JWT from the API on startup. If the user logged in previously (without logging out) the browser will have a valid refresh token cookie that is sent with the request to generate a new JWT. On success the app starts up already logged in on the home page, otherwise the login page is displayed.

Login Form with VeeValidate

The example login form is implemented with VeeValidate, it contains a username and password field which are both required. VeeValidate is a library for building, validating and handling forms in Vue.js. VeeValidate 4 was recently released and is compatible with Vue 3, the official docs are available at https://vee-validate.logaretm.com/v4.

Pinia State Management

Pinia is a new state management library built by the Vuejs core team that simplifies global state management, it is the successor to Vuex, requires much less code than Vuex and is the recommended state management library for Vue 3.

State and business logic are defined in Pinia using stores, each store can contain state, getters and actions. State defines the data managed by a store, getters return a value that is derived (computed) from state and/or other getters, and actions are methods used to execute business logic or asynchronous operations such as API calls. They are the equivalent of data, computed and methods in traditional (Options API) Vue components.

You can define as many Pinia stores as you like and they are globally accessible throughout your Vue application. For more info on Pinia see https://pinia.vuejs.org/core-concepts.

Fake Backend API

The Vue 3 + Pinia example app runs with a fake backend by default to enable it to run completely in the browser without a real backend API, to switch to a real backend API you just have to remove or comment out the 2 lines below the comment // setup fake backend located in the main.js file (/src/main.js).

You can build your own API or hook it up with a .NET API or Node.js API available below.

Styled with Bootstrap 5

The example login app is styled with the CSS from Bootstrap 5.2, for more info about Bootstrap see https://getbootstrap.com/docs/5.2/getting-started/introduction/.

Code on GitHub

The example project is available on GitHub at https://github.com/cornflourblue/vue-3-pinia-jwt-refresh-tokens.

Here it is in action: (See on StackBlitz at https://stackblitz.com/edit/vue-3-pinia-jwt-refresh-tokens)


Run the Vue 3 JWT with Refresh Tokens Example Locally

  1. Install NodeJS and NPM from https://nodejs.org.
  2. Download or clone the Vue project from https://github.com/cornflourblue/vue-3-pinia-jwt-refresh-tokens
  3. Install all required npm packages by running npm install or npm i from the command line in the project root folder (where the package.json is located).
  4. Start the application by running npm run dev from the command line in the project root folder.
  5. Open a browser and go to the application at http://localhost:3000


Connect the Vue App with a .NET 6.0 API

For full details about the .NET 6 API see .NET 6.0 - JWT Authentication with Refresh Tokens Tutorial with Example API. But to get up and running quickly just follow the below steps.

  1. Install the .NET SDK from https://dotnet.microsoft.com/download.
  2. Download or clone the project source code from https://github.com/cornflourblue/dotnet-6-jwt-refresh-tokens-api
  3. Start the API by running dotnet run from the command line in the project root folder (where the WebApi.csproj file is located), you should see the message Now listening on: http://localhost:4000.
  4. Back in the Vue 3 + Pinia app, remove or comment out the 2 lines below the comment // setup fake backend located in the /src/main.js file, then start the Vue app and it should now be hooked up with the .NET API.


Connect the Vue App with a Node.js + MongoDB API

For full details about the Node API see Node.js + MongoDB API - JWT Authentication with Refresh Tokens. But to get up and running quickly just follow the below steps.

  1. Install MongoDB Community Server from  https://www.mongodb.com/download-center/community.
  2. Run MongoDB, instructions are available on the install page for each OS at https://docs.mongodb.com/manual/administration/install-community/
  3. Download or clone the project source code from https://github.com/cornflourblue/node-mongo-jwt-refresh-tokens-api
  4. Install all required npm packages by running npm install or npm i from the command line in the project root folder (where the package.json is located).
  5. Start the api by running npm start (or npm run start:dev to start with nodemon) from the command line in the project root folder, you should see the message Server listening on port 4000, and you can view the Swagger API documentation at http://localhost:4000/api-docs.
  6. Back in the Vue 3 + Pinia example app, remove or comment out the 2 lines below the comment // setup fake backend located in the /src/main.js file, then start the Vue app and it should now be hooked up with the Node.js + MongoDB API.


Vue 3 Project Structure

The base project structure was generated with the official Vue project scaffolding tool create-vue by running the command npm init vue@latest and following the prompts. For more info about generating a new Vue 3 project from the command line see https://vuejs.org/guide/quick-start.html.

Folder structure

The project source (/src) is organised into the following folders:

  • assets
    Static assets such as CSS stylesheets and images.
  • helpers
    Anything that doesn't fit into the other folders and doesn't justify having its own folder.
  • stores
    Pinia state stores that define global state and actions for the Vue app.
  • views
    Vue components for the pages/views of the application.

The main.js file that bootstraps the app is located directly in the /src folder, alongside the root App component for the Vue 3 application.

JS file structure

JavaScript files are organised with export statements at/near the top so it's easy to see all exported modules when you open a file. Export statements are followed by functions and other implementation code for each JS module.

JS barrel Files

The index.js file in each folder is a barrel file that re-exports all of the modules from that folder so they can be imported using only the folder path instead of the full path to each module, and to enable importing multiple modules in a single import (e.g. import { HomeView, LoginView } from '@/views';).

Path Alias '@'

The alias @ is configured for the src folder in the vite.config.js file, so import statements prefixed with @ will be relative to the src folder of the project. This removes the need for long relative paths like import { MyComponent } from '../../../anotherFolder';.

Click any of the below links to jump down to a description of each file along with it's code:

 

Base (Global) CSS Styles

Path: /src/assets/base.css

The base stylesheet file contains CSS styles that are applied globally throughout the Vue application, it is imported in the <style> block of the App component.

.app-container {
    min-height: 350px;
}
 

Fake Backend API

Path: /src/helpers/fake-backend.js

In order to run and test the Vue 3 + Pinia app without a real backend API, the example uses a fake backend that intercepts the HTTP requests from the Vue app and sends back "fake" responses. This is done by monkey patching the window.fetch() function to return fake responses for a specific set of routes.

JS monkey patching

Monkey patching is a technique used to alter the behaviour of an existing function either to extend it or change the way it works. In JavaScript this is done by storing a reference to the original function in a variable and replacing the original function with a new custom function that (optionally) calls the original function before/after executing some custom code.

Fake backend structure

The fake backend is organised into a top level handleRoute() function that checks the request url and method to determine how the request should be handled. For fake routes one of the below // route functions is called, for all other routes the request is passed through to the real backend by calling the original fetch request function (realFetch(url, opts)). Below the route functions there are // helper functions for returning different response types and performing small tasks.

export { fakeBackend };

// array in local storage for users
const usersKey = 'vue-3-jwt-refresh-token-users';
const users = JSON.parse(localStorage.getItem(usersKey)) || [];

// add test user and save if users array is empty
if (!users.length) {
    users.push({ id: 1,  firstName: 'Test', lastName: 'User', username: 'test', password: 'test', refreshTokens: [] });
    localStorage.setItem(usersKey, JSON.stringify(users));
}

function fakeBackend() {
    let realFetch = window.fetch;
    window.fetch = function (url, opts) {
        return new Promise((resolve, reject) => {
            // wrap in timeout to simulate server api call
            setTimeout(handleRoute, 500);

            function handleRoute() {   
                const { method } = opts;             
                switch (true) {
                    case url.endsWith('/users/authenticate') && method === 'POST':
                        return authenticate();
                    case url.endsWith('/users/refresh-token') && method === 'POST':
                        return refreshToken();
                    case url.endsWith('/users/revoke-token') && method === 'POST':
                        return revokeToken();
                    case url.endsWith('/users') && method === 'GET':
                        return getUsers();
                    default:
                        // pass through any requests not handled above
                        return realFetch(url, opts)
                            .then(response => resolve(response))
                            .catch(error => reject(error));
                }
            }

            // route functions

            function authenticate() {
                const { username, password } = body();
                const user = users.find(x => x.username === username && x.password === password);

                if (!user) return error('Username or password is incorrect');

                // add refresh token to user
                user.refreshTokens.push(generateRefreshToken());
                localStorage.setItem(usersKey, JSON.stringify(users));

                return ok({
                    id: user.id,
                    username: user.username,
                    firstName: user.firstName,
                    lastName: user.lastName,
                    jwtToken: generateJwtToken()
                })
            }

            function refreshToken() {
                const refreshToken = getRefreshToken();
                
                if (!refreshToken) return unauthorized();

                const user = users.find(x => x.refreshTokens.includes(refreshToken));
                
                if (!user) return unauthorized();

                // replace old refresh token with a new one and save
                user.refreshTokens = user.refreshTokens.filter(x => x !== refreshToken);
                user.refreshTokens.push(generateRefreshToken());
                localStorage.setItem(usersKey, JSON.stringify(users));

                return ok({
                    id: user.id,
                    username: user.username,
                    firstName: user.firstName,
                    lastName: user.lastName,
                    jwtToken: generateJwtToken()
                })
            }

            function revokeToken() {
                if (!isLoggedIn()) return unauthorized();
                
                const refreshToken = getRefreshToken();
                const user = users.find(x => x.refreshTokens.includes(refreshToken));
                
                // revoke token and save
                user.refreshTokens = user.refreshTokens.filter(x => x !== refreshToken);
                localStorage.setItem(usersKey, JSON.stringify(users));

                return ok();
            }

            function getUsers() {
                if (!isLoggedIn()) return unauthorized();
                return ok(users);
            }

            // helper functions

            function ok(body) {
                resolve({ ok: true, text: () => Promise.resolve(JSON.stringify(body)) })
            }

            function unauthorized() {
                resolve({ status: 401, text: () => Promise.resolve(JSON.stringify({ message: 'Unauthorized' })) })
            }

            function error(message) {
                resolve({ status: 400, text: () => Promise.resolve(JSON.stringify({ message })) })
            }

            function isLoggedIn() {
                // check if jwt token is in auth header
                const authHeader = opts.headers['Authorization'] || '';
                if (!authHeader.startsWith('Bearer fake-jwt-token'))
                    return false;

                // check if token is expired
                try {
                    const jwtToken = JSON.parse(atob(authHeader.split('.')[1]));
                    const tokenExpired = Date.now() > (jwtToken.exp * 1000);
                    if (tokenExpired)
                        return false;
                } catch {
                    return false;
                }

                return true;
            }

            function body() {
                return opts.body && JSON.parse(opts.body);
            }

            function generateJwtToken() {
                // create token that expires in 15 minutes
                const tokenPayload = { exp: Math.round(new Date(Date.now() + 15*60*1000).getTime() / 1000) }
                return `fake-jwt-token.${btoa(JSON.stringify(tokenPayload))}`;
            }

            function generateRefreshToken() {
                const token = new Date().getTime().toString();

                // add token cookie that expires in 7 days
                const expires = new Date(Date.now() + 7*24*60*60*1000).toUTCString();
                document.cookie = `fakeRefreshToken=${token}; expires=${expires}; path=/`;

                return token;
            }

            function getRefreshToken() {
                // get refresh token from cookie
                return (document.cookie.split(';').find(x => x.includes('fakeRefreshToken')) || '=').split('=')[1];
            }
        });
    }
}
 

Fetch Wrapper

Path: /src/_helpers/fetch-wrapper.js

The fetch wrapper is a lightweight wrapper around the native browser fetch() function used to simplify the code for making HTTP requests. It returns an object with methods for get, post, put and delete requests, it automatically handles the parsing of JSON data from responses, and throws an error if the HTTP response is not successful (!response.ok). If the response is 401 Unauthorized or 403 Forbidden the user is automatically logged out of the Vue 3 + Pinia app.

The authHeader() function is used to automatically add a JWT auth token to the HTTP Authorization header of the request if the user is logged in and the request is to the application API URL. The current logged in state of the user is retrieved from the Pinia auth store.

The handleResponse() function parses the response and returns a JSON object. It automatically logs the user out if a 401 Unauthorized or 403 Forbidden response is returned from the API.

With the fetch wrapper a POST request can be made by simply calling fetchWrapper.post(url, body). It's used in the example app by the auth store and users store.

import { useAuthStore } from '@/stores';

export const fetchWrapper = {
    get: request('GET'),
    post: request('POST'),
    put: request('PUT'),
    delete: request('DELETE')
};

function request(method) {
    return (url, body, { credentials } = {}) => {
        const requestOptions = {
            method,
            headers: authHeader(url)
        };
        if (body) {
            requestOptions.headers['Content-Type'] = 'application/json';
            requestOptions.body = JSON.stringify(body);
        }
        if (credentials) {
            requestOptions.credentials = credentials;
        }
        return fetch(url, requestOptions).then(handleResponse);
    }
}

// helper functions

function authHeader(url) {
    // return auth header with jwt if user is logged in and request is to the api url
    const { user } = useAuthStore();
    const isLoggedIn = !!user?.jwtToken;
    const isApiUrl = url.startsWith(import.meta.env.VITE_API_URL);
    if (isLoggedIn && isApiUrl) {
        return { Authorization: `Bearer ${user.jwtToken}` };
    } else {
        return {};
    }
}

function handleResponse(response) {
    return response.text().then(text => {
        const data = text && JSON.parse(text);
        
        if (!response.ok) {
            const { user, logout } = useAuthStore();
            if ([401, 403].includes(response.status) && user) {
                // auto logout if 401 Unauthorized or 403 Forbidden response returned from api
                logout();
            }

            const error = (data && data.message) || response.statusText;
            return Promise.reject(error);
        }

        return data;
    });
}
 

Vue 3 Router

Path: /src/helpers/router.js

The router defines the routes for the Vue 3 application and creates a new Vue Router instance with the createRouter() function. The exported router instance is imported into main.js where it is passed to the Vue app on startup.

The createRouter() function is part of Vue Router v4 which is compatible with Vue 3, the previous version of the router (Vue Router v3) is compatible with Vue 2. For more info on what's changed in the new version of the Vue Router see https://next.router.vuejs.org/guide/migration/.

The home route maps the root path ('/') of the app to the HomeView component and the '/login' route maps to the LoginView component.

Unauthenticated users are prevented from accessing restricted pages by the function passed to router.beforeEach().

The linkActiveClass: 'active' parameter sets the active CSS class on <RouterLink> components to "active" to make the nav bar links in the example compatible with Bootstrap CSS. The default active class for a Vue RouterLink is "router-link-active".

For more information on Vue routing see https://router.vuejs.org/.

import { createRouter, createWebHistory } from 'vue-router';

import { useAuthStore } from '@/stores';
import { HomeView, LoginView } from '@/views';

export const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    linkActiveClass: 'active',
    routes: [
        { path: '/', component: HomeView },
        { path: '/login', component: LoginView },

        // otherwise redirect to home
        { path: '/:pathMatch(.*)*', redirect: '/' }
    ]
});

router.beforeEach(async (to) => {
    // redirect to login page if not logged in and trying to access a restricted page
    const publicPages = ['/login'];
    const authRequired = !publicPages.includes(to.path);
    const authStore = useAuthStore();

    if (authRequired && !authStore.user) {
        return {
            path: '/login',
            query: { returnUrl: to.href }
        };
    }
});
 

Pinia Auth Store

Path: /src/stores/auth.store.js

The Pinia auth store manages state and handles communication between the Vue app and the backend api for everything related to authentication. It contains action methods for login, logout and refresh token, and contains a property for accessing the current user.

Auth service methods and properties

The user state property provides global access to the current logged in user.

The login() method POSTs the username and password to the API for authentication, on success the api returns the user details, a JWT token and a refresh token cookie. The method then starts a countdown timer by calling this.startRefreshTokenTimer() to auto refresh the JWT token in the background (silent refresh) one minute before it expires so the user stays logged in.

The logout() method makes a POST request to the API to revoke the refresh token that is stored in a browser cookie, cancels the silent refresh running in the background by calling this.stopRefreshTokenTimer(), then logs the user out by setting the user state property to null and redirects to the login page.

The refreshToken() method is similar to the login() method, they both perform authentication, but this method does it by making a POST request to the API that includes a refresh token cookie instead of username and password. On success the api returns the user details, a new JWT token and a new refresh token cookie. The method then starts a countdown timer by calling this.startRefreshTokenTimer() to auto refresh the JWT token in the background (silent refresh) one minute before it expires so the user stays logged in.

import { defineStore } from 'pinia';

import { fetchWrapper, router } from '@/helpers';

const baseUrl = `${import.meta.env.VITE_API_URL}/users`;

export const useAuthStore = defineStore({
    id: 'auth',
    state: () => ({
        user: null,
        refreshTokenTimeout: null
    }),
    actions: {
        async login(username, password) {
            this.user = await fetchWrapper.post(`${baseUrl}/authenticate`, { username, password }, { credentials: 'include' });
            this.startRefreshTokenTimer();
        },
        logout() {
            fetchWrapper.post(`${baseUrl}/revoke-token`, {}, { credentials: 'include' });
            this.stopRefreshTokenTimer();
            this.user = null;
            router.push('/login');
        },
        async refreshToken() {
            this.user = await fetchWrapper.post(`${baseUrl}/refresh-token`, {}, { credentials: 'include' });
            this.startRefreshTokenTimer();
        },
        startRefreshTokenTimer() {
            // parse json object from base64 encoded jwt token
            const jwtBase64 = this.user.jwtToken.split('.')[1];
            const jwtToken = JSON.parse(atob(jwtBase64));
    
            // set a timeout to refresh the token a minute before it expires
            const expires = new Date(jwtToken.exp * 1000);
            const timeout = expires.getTime() - Date.now() - (60 * 1000);
            this.refreshTokenTimeout = setTimeout(this.refreshToken, timeout);
        },    
        stopRefreshTokenTimer() {
            clearTimeout(this.refreshTokenTimeout);
        }
    }
});
 

Pinia Users Store

Path: /src/stores/users.store.js

The users store contains Pinia state and actions for users in the Vue 3 app.

The users state property is used to store all users fetched from the API. It defaults to an empty object and can hold one the following values:

  • {} - initial state.
  • { loading: true } - users are currently being fetched from the API.
  • [{ ... }, { ... }, { ... }] - array of users returned by the API.
  • { error: 'an error message' } - request to the API failed and an error was returned.

The Pinia getAll() action method fetches the users from the API and updates the users state property based on the result.

import { defineStore } from 'pinia';

import { fetchWrapper } from '@/helpers';

const baseUrl = `${import.meta.env.VITE_API_URL}/users`;

export const useUsersStore = defineStore({
    id: 'users',
    state: () => ({
        users: {}
    }),
    actions: {
        async getAll() {
            this.users = { loading: true };
            fetchWrapper.get(baseUrl)
                .then(users => this.users = users)
                .catch(error => this.users = { error })
        }
    }
});
 

Vue 3 HomeView Component

Path: /src/views/HomeView.vue

The home view is displayed after signing in to the application, it shows the signed in user's name plus a list of all users in the tutorial application. The users are loaded into Pinia state by calling usersStore.getAll().

The users list is displayed if the users state property contains an array with at least 1 item, which is checked with the Vue directive v-if="users.length" on the <ul> element.

A loading spinner is displayed while the API request for users is in progress/loading, and an error message is displayed if the request fails.

<script setup>
import { storeToRefs } from 'pinia';

import { useAuthStore, useUsersStore } from '@/stores';

const authStore = useAuthStore();
const { user: authUser } = storeToRefs(authStore);

const usersStore = useUsersStore();
const { users } = storeToRefs(usersStore);

usersStore.getAll();
</script>

<template>
    <div>
        <h1>Hi {{authUser?.firstName}}!</h1>
        <p>You're logged in with Vue 3 + JWT with Refresh Tokens!!</p>
        <h3>Users from secure api end point:</h3>
        <ul v-if="users.length">
            <li v-for="user in users" :key="user.id">{{user.firstName}} {{user.lastName}}</li>
        </ul>
        <div v-if="users.loading" class="spinner-border spinner-border-sm"></div>
        <div v-if="users.error" class="text-danger">Error loading users: {{users.error}}</div>
    </div>
</template>
 

Vue 3 LoginView Component

Path: /src/views/LoginView.vue

The login view contains a form built with the VeeValidate library that contains username and password fields for logging into the Vue 3 + Pinia app.

Form validation rules are defined with the Yup schema validation library which VeeValidate supports out of the box, for more info on Yup see https://github.com/jquense/yup.

The onSubmit() method posts the user credentials to the API by calling authStore.login(). On successful authentication the user auth data is stored in Pinia global state by the login() action method in the auth store, and the user is redirected to the home page.

The Vue component template contains the form with input fields and validation messages. The form and fields are built with the VeeValidate <Form /> and <Field /> components which automatically hook into the validation rules (schema) based on the name of the field.

The form calls the onSubmit() method when the form is submitted and valid. Validation rules are bound to the form with the validation-schema prop, and validation errors are provided to the form template via the scoped slot v-slot="{ errors }". For more info on form validation with Vue 3 and VeeValidate see Vue 3 + VeeValidate - Form Validation Example (Composition API).

<script setup>
import { useRoute } from 'vue-router';
import { Form, Field } from 'vee-validate';
import * as Yup from 'yup';

import { router } from '@/helpers';
import { useAuthStore } from '@/stores';

const route = useRoute();
const authStore = useAuthStore();

// redirect to home if already logged in
if (authStore.user) {
    router.push('/');
}

const schema = Yup.object().shape({
    username: Yup.string().required('Username is required'),
    password: Yup.string().required('Password is required')
});

function onSubmit(values, { setErrors }) {
    const { username, password } = values;

    return authStore.login(username, password)
        .then(() => {
            // redirect to previous url or default to home page
            router.push(route.query.returnUrl || '/');
        })
        .catch(error => setErrors({ apiError: error }));
}
</script>

<template>
    <div class="col-md-6 offset-md-3 mt-5">
        <div class="alert alert-info">
            Username: test<br />
            Password: test
        </div>
        <h2>Login</h2>
        <Form @submit="onSubmit" :validation-schema="schema" v-slot="{ errors, isSubmitting }">
            <div class="mb-3">
                <label class="form-label">Username</label>
                <Field name="username" type="text" class="form-control" :class="{ 'is-invalid': errors.username }" />
                <div class="invalid-feedback">{{errors.username}}</div>
            </div>            
            <div class="mb-3">
                <label class="form-label">Password</label>
                <Field name="password" type="password" class="form-control" :class="{ 'is-invalid': errors.password }" />
                <div class="invalid-feedback">{{errors.password}}</div>
            </div>            
            <div class="mb-3">
                <button class="btn btn-primary" :disabled="isSubmitting">
                    <span v-show="isSubmitting" class="spinner-border spinner-border-sm me-1"></span>
                    Login
                </button>
            </div>
            <div v-if="errors.apiError" class="alert alert-danger mt-3 mb-0">{{errors.apiError}}</div>
        </Form>
    </div>
</template>
 

Vue 3 App Component

Path: /src/App.vue

The App component is the root component of the example Vue 3 + Pinia app, it contains the main nav bar which is only displayed for authenticated users, and a RouterView component for displaying the contents of each view based on the current route / path.

The user state property of the Pinia auth store is used to reactively show/hide the main nav bar when the user logs in/out of the application.

The authStore.logout() method is called from the logout link in the main nav bar to log the user out and redirect to the login page.

<script setup>
import { RouterLink, RouterView } from 'vue-router';

import { useAuthStore } from '@/stores';

const authStore = useAuthStore();
</script>

<template>
    <div class="app-container bg-light">
        <nav v-show="authStore.user" class="navbar navbar-expand navbar-dark bg-dark px-3">
            <div class="navbar-nav">
                <RouterLink to="/" class="nav-item nav-link">Home</RouterLink>
                <button @click="authStore.logout()" class="btn btn-link nav-item nav-link">Logout</button>
            </div>
        </nav>
        <div class="container pt-4 pb-4">
            <RouterView />
        </div>
    </div>
</template>

<style>
@import '@/assets/base.css';
</style>
 

Vue 3 Main.js

Path: /src/main.js

The main.js file bootstraps the Vue application by mounting the App component in the #app div element defined in the main index html file.

Pinia and router config

Pinia support is added to the Vue app with the line app.use(createPinia()).

Vue routes are configured with the call to app.use(router), routes are defined in router.js.

Fake backend enabled

Before starting the Vue app it imports and enables the fake backend api. To disable the fake backend simply remove the 2 lines below the comment // setup fake backend.

Auto login with refresh token

An attempt to automatically login to the Vue app on startup is made by calling authStore.refreshToken() which sends a request to the API for a new JWT auth token. If the user previously logged in (without logging out) the browser will have a valid refresh token cookie that is sent with the request to generate a new JWT. On success the app starts on the home page with the user already logged in, otherwise the login page is displayed. The try-catch block is used to ensure the Vue app always starts even if the refreshToken() request fails.

import { createApp } from 'vue';
import { createPinia } from 'pinia';

import App from './App.vue';
import { router } from './helpers';
import { useAuthStore } from './stores';

// setup fake backend
import { fakeBackend } from './helpers';
fakeBackend();

startApp();

// async start function to enable waiting for refresh token call
async function startApp () {
    const app = createApp(App);

    app.use(createPinia());
    app.use(router);

    // attempt to auto refresh token before startup
    try {
        const authStore = useAuthStore();
        await authStore.refreshToken();
    } catch {
        // catch error to start app on success or failure
    }

    app.mount('#app');
}
 

dotenv

Path: /.env

The dotenv file contains environment variables used in the example Vue app, the API URL is used in the auth store and users store to send HTTP requests to the API.

Environment variables set in the dotenv file that are prefixed with VITE_ are accessible in the Vue app via import.meta.env.<variable name> (e.g. import.meta.env.VITE_API_URL). For more info on using environment variables in a Vue app built with Vite see https://vitejs.dev/guide/env-and-mode.html.

VITE_API_URL=http://localhost:4000
 

Main Index Html File

Path: /index.html

The main index.html file is the initial page loaded by the browser that kicks everything off. The Vite dev server runs the application in development mode with the command npm run dev.

Rather than bundling the javascript modules like traditional front-end build tools, Vite takes advantage of native ES module support in modern browsers and adds extra features like hot module replacement to support development. For more info on the Vite dev server features see https://vitejs.dev/guide/features.html.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vue 3 + Pinia - JWT Authentication with Refresh Tokens Example & Tutorial</title>

    <!-- bootstrap css -->
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
</head>

<body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
</body>

</html>
 

Package.json

Path: /package.json

The package.json file contains project configuration information including package dependencies that get installed when you run npm install and scripts that are executed when you run npm run dev or npm run build etc. Full documentation is available at https://docs.npmjs.com/files/package.json.

{
    "name": "vue-3-pinia-jwt-refresh-tokens",
    "version": "0.0.0",
    "scripts": {
        "dev": "vite",
        "build": "vite build",
        "preview": "vite preview --port 5050",
        "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
    },
    "dependencies": {
        "pinia": "^2.0.13",
        "vee-validate": "^4.5.11",
        "vue": "^3.2.33",
        "vue-router": "^4.0.14",
        "yup": "^0.32.11"
    },
    "devDependencies": {
        "@vitejs/plugin-vue": "^2.3.1",
        "eslint": "^8.5.0",
        "eslint-plugin-vue": "^8.2.0",
        "vite": "^2.9.5"
    }
}
 

Vite.js Configuration

Path: /vite.config.js

Configuration for Vite, the official front-end build tool and dev server used by Vue projects created with the create-vue command npm init vue@latest.

The plugin @vitejs/plugin-vue provides support for Vue 3 single file components (SFC).

The alias '@' makes import statements prefixed with an at symbol (@) relative to the /src folder of the project.

For more info on Vite configuration options see https://vitejs.dev/config/.

import { fileURLToPath, URL } from 'url';

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

// https://vitejs.dev/config/
export default defineConfig({
    plugins: [vue()],
    resolve: {
        alias: {
            '@': fileURLToPath(new URL('./src', import.meta.url))
        }
    }
});

 


Need Some Vue 3 Help?

Search fiverr for freelance Vue 3 developers.


Follow me for updates

On Twitter or RSS.


When I'm not coding...

Me and Tina are on a motorcycle adventure around Australia.
Come along for the ride!


Comments


Supported by