Json Web Tokens are widely used in moderne web applications. They are a really convenient way to manage user authentication in a statless system and easy to use for requesting data on API by passing it on headers or cookies.
You will find a lot of blog articles and comments online about the fact of using JWT as session token or how to store tokens in a proper way. If you use a fullstack framework like Django, NextJS, Play, etc, there is strong arguments for not using JWT as session token and prefering the usage of the included session mechanisms of the framework.
In the case of Single Page Application, it's more difficult to define a good or a bad practice. This kind of application are commonly calling API for getting datas and backend developpers will always prefer to develop stateless API and receiving a token in header for manage authentication. You should always design your system for making it easy to scale and distribute. So, include session management in your API will probably be a complexity to solve when you will have to scale. That's why creating stateless API and using JWT are a convenient way to avoid futur pain.
Now the challenge is "how to store my JWT in a secure way with my SPA". The easiest way will probably to store it in the localstorage or the sessionstorage of the browser. By example, the SaaS solution for Identity And Access management Auth0, provide a Javascript SDK doing this. By using oAuth protocol, you will probably have to deal with idToken and refresh token. So storing issue is the same.
Using the storages of the browser will be a security issue. Javascript can access them so, if your application is vulnerable to XSS attack, it will be easy to stole tokens. Cookies are on the same battlefield, except by using HttpOnly and secure cookie. But by this way, your own javascript will not have access for reading the cookie and, by consequence, will not be able to read the JWT or refresh it when it expires. You will have to build other mechanisms for implementing all these things.
Alternative to browser storage
The purpose of this article is to explain an alternative of storing tokens on client side but, obviously, This doesn't mean that this solution should be avoid. Like explain before, this practice is not bad but you should be avare of the security issues that this can involve. You should consider you security threat model, even make a pentest based on the exposure of your application.
The technologies, languages and products exposed in the rest of the article are used for demonstrate a concept. The same result can be reached with many others solutions.
Concept
Now, let's take a look on the next schema
We have 3 main components :
- SPA - behind the domain demo.com - NGinx providing the SPA
- API - behind the domain api.demo.com - basic REST API managing authentication by receiving JWT in Authorization Header
- auth-gateway-express - behind the domain auth.demo.com - manage oAuth flow with the IAM component / store tokens on session
For the IAM part, I'm using Auth0. All this components are behind a reverse proxy. I choose Traefik for an interesting middleware that I will talk about very soon.
The following sequence diagram illustrate what happen on the SPA loading and for the authentication process:
For the loading of the SPA, nothing much to say. The reverse proxy forward the call to NGinx and forward back the provided static sources to the client.
The authentication process has more interactions. The SPA is calling the auth-gateway behind auth.demo.com on /login url. The next interactions are just a normal oauth authorization_code grant flow. If you're not familiar with that, just take a look on the this article of the Okta blog. Usually, with an SPA, the token endpoint is called by the frontend and that's the moment where you have to deal with token storage. In our situation, this is the auth-gateway that is receiving it. It just have to store all the datas on a session and provide a cookie for the browser.
At the end of the full process, the SPA is loaded on the browser and have a HTTPOnly and secure cookie. For managing a conditional authenticated display, we can make a call on the auth-gatway for getting user informations for example. If the cookie is absent or the session not existing, the gateway reject the call and the SPA can launch a new authentication process.
Reverse proxy
Because we want our API stateless and don't want to implement a previous call on the auth-gateway for getting the access token, we need a mecanism for exchanging our session cookie for the issued access token. This is where the Traefik middlware I mentioned earlier comes in. It's called "ForwardAuth" and we're configuring it for calling a specifing endpoint before forwarding the request to the destination.
(Schema from https://doc.traefik.io/traefik/middlewares/forwardauth/)
In our context, when the SPA will fetch a resource on the api.demo.com, Traefik's middleware will forward the request to the auth-gateway, copy the header "Authorization" from this request to the initial request and forward to the API.
Implementation
That's it for the theory ! Now, let's take a look on an implementation example. You will find the code on my Github: https://github.com/mettany/spa-authenticated-poc
Auth-gateway-express
In this folder, you will find the implementation of the auth-gateway using Node.JS with express.
const express = require("express");
const cors = require("cors");
const session = require("express-session");
const config = require("./lib/config");
const { login, token, getUserInfos, authProxy } = require("./lib/auth");
const app = express();
const appAdmin = express();
const sessionMiddlware = session(config.session);
const corsMiddleware = cors({ origin: true, credentials: true});
app.use(sessionMiddlware);
appAdmin.use(sessionMiddlware);
app.use(corsMiddleware);
appAdmin.use(corsMiddleware);
app.get("/login", login);
app.get("/callback", token);
app.get("/userinfos", getUserInfos);
appAdmin.get("/auth", authProxy);
app.listen(config.port, () => console.log(`App listening on port ${config.port}`));
appAdmin.listen(3001, () => console.log("AppAdmin listening on port 3001"));
We can see in the code above that we are declaring two express applications; app and appAdmin. app is exposing the /login, /callback and /userinfos endpoints and appAdmin the /auth endpoint. They are listening on two different ports. The reason is simple: we don't want the /auth route, called by the forwardAuth middleware, accessible from client side. It should only be accessed behind the reverse proxy.
// lib/auth.js
const authProxy = (req, res) => {
if(!req.session.auth) {
res.status(401).end();
return;
}
res.set("Authorization", `Bearer ${req.session.auth.access_token}`);
res.end();
};
The function behind /auth is simply tring to get the session and if exists, adding the access token in a Authorization header. This function should also manage the check on the expiration of the token and the refresh mechanism.
The lib/auth.js is also containing other functions to manage the oauth flow.
Api-example
The name is pretty obvious. This folder is containing a Node.JS / express implementation of the api.demo.com. It expose one route (/authorized) that checks if the token received in the Authorization header is valid.
// index.js
const express = require("express");
const cors = require("cors");
const jwt = require("express-jwt");
const jwks = require("jwks-rsa");
const config = require("./lib/config");
const jwtCheck = jwt({
secret: jwks.expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `${config.issuerBaseURL}.well-known/jwks.json`
}),
audience: config.audience,
issuer: config.issuerBaseURL,
algorithms: ["RS256"]
});
const app = express();
app.use(cors({ origin: true, credentials: true}));
app.use(jwtCheck);
app.get("/authorized", function (req, res) {
res.send("Secured Resource");
});
app.listen(config.port, () => console.log(`App listening on port ${config.port}`));
SPA-example
For the SPA, not much to say. It's a simple React application with a "Login" button if we are not connected and a "Call API" button if we are.
The login button is simply redirecting us to the /login url of the auth-gateway and the "Call API" is performing a get request on the api.demo.com.
For checking if the user is logged in, we have the following function :
// src/App.js
const getUserInfo = async (cb) => {
try {
const response = await axios.get(`${window.ENV.AUTH_URL}/userinfos`, { withCredentials: true });
cb(response.data);
} catch(error) {
cb(null);
}
}
This function is called with a useEffect hook on the app loading
const App = () => {
const [user, setUser] = useState(null);
useEffect(() => {
getUserInfo(setUser);
}, []);
return (
...
);
}
Build and deploy
Each project contains a Dockerfile for build and containerize the app. On the root folder, there is a small build.sh script who builds each app.
Finally, the root folder containing also a docker-compose.yml file for deploying all apps and the Traefik. The Traefik configuration deployed with docker is working with labels. We just need to bind it on the docker socket for allowing it to listen events.
services:
reverse-proxy:
image: traefik:v2.4.6
# Enables the web UI and tells Traefik to listen to docker
command:
- --entrypoints.web.address=:80
- --api.insecure=true
- --providers.docker
- --log.level=DEBUG
ports:
# The HTTP port
- "80:80"
# The Web UI (enabled by --api.insecure=true)
- "8080:8080"
volumes:
# So that Traefik can listen to the Docker events
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
networks:
- stack-network
labels:
- traefik.http.middlewares.api-forwardauth.forwardauth.address=http://auth-gateway:3001/auth
- traefik.http.middlewares.api-forwardauth.forwardauth.trustForwardHeader=true
- traefik.http.middlewares.api-forwardauth.forwardauth.authRequestHeaders=Cookie
- traefik.http.middlewares.api-forwardauth.forwardauth.authResponseHeaders=Authorization
We can see the labels for configuring the forwardauth middleware.
So ! You have now a more secured alternative for storing your security tokens out of the browser. Thank you for reading !