Authentication is something that almost every modern application has. Although not being a rocket science, subject is still complex enough and yields questions, especially with less experienced developers. In this post I will talk about general steps for token authentication in SPA and will give an example for JWT authentication in React app.
Authentication flow, step-by-step
- Get access and refresh token from API. On this step we usually ask user for login and password (in case of self-hosted solution) or show 3rd-party popups (in case of OAuth). In case of 3rd-party providers this step will have few sub-steps which I do not discuss here for the simplicity.
- Store access and refresh tokens in local storage (or in cookies if for some reason you prefer that). This is is needed so user will stay authenticated through page reloads (page reloads are not supposed to happen in SPA but user may hit that button, accidentally or not).
- Save tokens and authentication state (i.e. user id, some boolean property) in some centralized memory storage. Examples: Redux, Context API or some singleton object.
- Determine and save expiration time of access token and setup a timer that will check expiration periodically and get fresh access token when needed.
- When you refresh token, you should persist it (2) and update global authentication state (3).
- On page reload, check if tokens are persisted in local storage (2) and valid. If true, go to step 3, otherwise start from step 1.
- (This part of process is usually called authorization). Make all your components that display sensitive information react to changes in authentication state (3) by hiding or displaying content. Also, attach authentication header to all requests that access protected data.
Example of simple JWT-based authentication
What is JWT
JSON Web Token (JWT, sometimes pronounced /dʒɒt/[1]) is an Internet standard for creating data with optional signature and/or optional encryption whose payload holds JSON that asserts some number of claims. The tokens are signed either using a private secret or a public/private key.
– Wikipedia
In more simple words, JWT is a plain JavaScript object that is signed and packed into base64 string. Information that JWT contains usually includes user id and set of permissions. It is important to not store anything sensitive in JWT tokens as they can be decoded without knowing a secret. Server that issued JWT checks correctness and validity of information using secret key. Therefore, malicious user can not forge JWT token. However, it is important to keep JWT tokens short-lived and use HTTPS protocol in production.
Code sample
In example below, user enters their login and password and if credentials are valid, server returns short-lived (15m) JWT. There is no separate refresh token in my case. Instead, I use current access token to get new one. If you want to implement “Remember me” feature, you need to have separate long-lived refresh token with ability to revoke it. But general steps and code will not be very different.
I do not use Redux or Context API in this example. Instead, I have a global object (singleton) responsible for authentication state. Posting only important part of code, with comments.
import { useCallback, useState, useEffect, useMemo } from "react"; //This library helps to decode JWT on client. On server, the same library is used to create and verify tokens import JWT from "jsonwebtoken"; //Just a custom hook returning additional headers that my server needs import { useLanguageHeaders } from "./i18n"; //Helper function that returns decoded token if string is passed and null otherwise const decodeToken = (token) => typeof token === "string" ? JWT.decode(token) : null; //This class contain all logic related to tokens storage and refresh class AuthProvider { //Returns authentication state, including current token, user Id and bool state get authStatus() { return this._authStatus; } constructor({ tokenEndpoint, refreshEndpoint, refreshLeeway = 60 }) { this._tokenEndpoint = tokenEndpoint; this._refreshEndpoint = refreshEndpoint; this._refreshLeeway = refreshLeeway; //Load persisted tokens (if any) and start function that checks expiration date and performs refresh this._loadToken(); this._maybeRefresh(); } //This function is called to get a token given login and password async authenticate(formData, headers = {}) { const response = await fetch(this._tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/json", ...headers, }, redirect: "follow", body: JSON.stringify(formData), }); const body = await response.json(); if (response.status === 200) { //If response status is 200, update auth state and persist token this._updateToken(body.token); } else { //Otherwise remove persisted token (if any) and update auth state accordingly this._updateToken(null); throw body; } } //This is will be called to log out unauthenticate() { this._updateToken(null); } //Part of simplified event system allowing components to track changes in auth state addStatusListener(listener) { this._statusListeners.push(listener); } //Also part of event system removeStatusListener(listener) { this._statusListeners = this._statusListeners.filter( (cb) => cb !== listener ); } _storageKey = "jwt"; //Token will be refreshed if is due to expire in next 60 seconds _refreshLeeway = 60; _tokenEndpoint = ""; _refreshEndpoint = ""; _refreshTimer = undefined; _authStatus = { isAuthenticated: null, token: null, userId: null, }; _statusListeners = []; //This method is responsible for token refresh //It is a performance-critical part because this method will be called every second async _maybeRefresh() { clearTimeout(this._refreshTimer); try { const decodedToken = decodeToken(this._token); if (decodedToken === null) { //No refresh token return; } //Note that in case of JWT, expiration date is stored in token //itself, so I do not need to make network requests to check expiration //Otherwise you might want to store expiration date in _authStatus //and localStorage, to not spam your API with unneeded requests //every second if ( decodedToken.exp * 1000 - new Date().valueOf() > this._refreshLeeway * 1000 ) { //Access token is not due return; } if (decodedToken.exp * 1000 <= new Date().valueOf()) { //Both access token and refresh token (in my case they are the same) are expired this._updateToken(null); throw new Error("Token is expired"); } //Access token is going to expire soon and we have valid refresh token //Make a request to get new access token const response = await fetch(this._refreshEndpoint, { method: "POST", headers: { "Content-Type": "application/json", }, redirect: "follow", body: JSON.stringify({ token: this._token }), }); const body = await response.json(); if (response.status === 401) { //401 means that refresh token was revoked or user blocked or something else like that //In such cases we need to remove stored tokens this._updateToken(null); throw body; } else if (response.status === 200) { //Save new access token in memory and persist this._updateToken(body.token); } else { //If it's not 401, might be some network problem. No need to do anything with tokens, let's try again later throw body; } } catch (e) { console.log("Something is wrong when trying to refresh token", e); } finally { //Retry in 1 second //Fun fact - code in finally section executes even if try section has return statement this._refreshTimer = setTimeout(this._maybeRefresh.bind(this), 1000); } } //This method checks token for readability, updates auth state and notifies subscribers about changes _updateToken(token) { this._token = token; this._saveCurrentToken(); try { const decodedToken = decodeToken(this._token); if (decodedToken === null) { //No token this._authStatus = { ...this._authStatus, isAuthenticated: false, token: null, userId: null, }; } else if (decodedToken.exp * 1000 <= new Date().valueOf()) { //Token expired this._authStatus = { ...this._authStatus, isAuthenticated: false, token: null, userId: null, }; } else { //Token is fine this._authStatus = { ...this._authStatus, isAuthenticated: true, token, userId: decodedToken.id, }; } } catch (e) { //Probably malformed token because library failed to decode this._token = null; this._saveCurrentToken(); this._authStatus = { ...this._authStatus, isAuthenticated: false, token: null, userId: null, }; console.log(e); } finally { //Notify subscribers about changes this._statusListeners.forEach((listener) => listener(this._authStatus)); } } _loadToken() { this._updateToken(window.localStorage.getItem(this._storageKey)); } _saveCurrentToken() { if (typeof this._token === "string") { window.localStorage.setItem(this._storageKey, this._token); } else { window.localStorage.removeItem(this._storageKey); } } } //Create authProvider instance that will be a singleton export const authProvider = new AuthProvider({ tokenEndpoint: "https://server.com/auth/token", refreshEndpoint: "https://server.com/auth/refresh", refreshLeeway: 60, }); //This hook returns async function that authenticates user export const useAuthenticate = () => { const headers = useLanguageHeaders(); return useCallback( async (formData) => { await authProvider.authenticate(formData, headers); }, [headers] ); }; //This hook returns a function that logs user out export const useUnauthenticate = () => { return useCallback(() => authProvider.unauthenticate(), []); }; //This hook allows other components to know if user is authenticated or not export const useAuthStatus = () => { const [authStatus, setAuthStatus] = useState(authProvider.authStatus); useEffect(() => { //When component is mounted, subscribe to checges in auth state authProvider.addStatusListener(setAuthStatus); return () => { //Unsubscribe on unmount authProvider.removeStatusListener(setAuthStatus); }; }, []); return authStatus; }; //This hook returns headers that needed to authorize requests export const useAuthHeaders = () => { const { token } = useAuthStatus(); return useMemo(() => { if (typeof token !== "string") { return {}; } return { Authorization: `Bearer ${token}` }; }, [token]); };
If you are using react-router, your AuthenticatedRoute will look like this:
import React from "react"; import { Route, Redirect } from "react-router-dom"; import { useAuthStatus } from "../modules/auth"; export const AuthenticatedRoute = ({ redirectTo = "/auth/login", children, ...rest }) => { const { isAuthenticated } = useAuthStatus(); if (isAuthenticated === true) { return <Route {...rest} render={() => children} />; } if (isAuthenticated === null) { return null; } return ( <Route {...rest} render={({ location }) => ( <Redirect to={{ pathname: redirectTo, state: { from: location }, }} /> )} /> ); };
And here is a module to perform authorized http(s) requests:
import { useLanguageHeaders } from "./i18n"; import { useCallback } from "react"; import { authProvider, useAuthHeaders } from "./auth"; import { ENV } from "../config"; import queryString from "query-string"; /** * This function builds url from parts * It will ensure that url has one slash between each part * i.e. urlConcat('api.example.com/', '/v1/', 'user') will return * 'api.example.com/v1/user' */ export const urlConcat = function () { return Array.prototype.slice .call(arguments, 0) .map((arg) => String(arg).trim()) .join("/") .replace(/\/+/gm, "/") .replace(/http(s?):\//gm, "$&/"); }; //Wrapper around fetch const fetchItRight = async ( url, { languageHeaders = {}, authHeaders = {}, authenticate = true, query = {}, fetchOptions = {}, } = {} ) => { let headers = { "Content-Type": "application/json", ...languageHeaders, }; if (authenticate) { headers = { ...headers, ...authHeaders }; } const schemaRe = /^https?:\/\//i; let compiledUrl = [ schemaRe.test(url) ? url : urlConcat(ENV.API.host, ENV.API.namespace, url), queryString.stringify(query, { arrayFormat: "comma" }), ] .filter((v) => typeof v === "string" && v.length > 0) .join("?"); const response = await fetch(compiledUrl, { headers: headers, redirect: "follow", cache: "no-store", mode: "cors", credentials: "same-origin", ...fetchOptions, }); let body = null; try { body = await response.json(); } catch (e) { //do nothing, I do not care if server did not return a body } if (response.status >= 200 && response.status < 400) { return body; } else { if (response.status === 401) { console.error("Server returned 401, logging out", body); //Something is wrong with token, unauthenticate just in case authProvider.unauthenticate(); } throw body; } }; //This hooks provides components with function to perform an authorized GET request export const useGet = () => { const languageHeaders = useLanguageHeaders(); const authHeaders = useAuthHeaders(); return useCallback( async ( url, { authenticate = true, query = {}, fetchOptions = {} } = {} ) => { return await fetchItRight(url, { languageHeaders, authHeaders, authenticate, query, fetchOptions: { method: "GET", ...fetchOptions }, }); }, [languageHeaders, authHeaders] ); }; //Hook for POST requests export const usePost = () => { const languageHeaders = useLanguageHeaders(); const authHeaders = useAuthHeaders(); return useCallback( async ( url, data = {}, { authenticate = true, query = {}, fetchOptions = {} } = {} ) => { return await fetchItRight(url, { languageHeaders, authHeaders, authenticate, query, fetchOptions: { method: "POST", body: JSON.stringify(data), ...fetchOptions, }, }); }, [languageHeaders, authHeaders] ); };
This is pretty much everything that you need to know to implement frontend part of authentication/authorization process. As for backend part you can use jsonwebtoken package to generate signed tokens and validate them. Library docs has examples of signing and validation. Hope this post will be helpful for someone.
Featured image source – pxhere.com