Authentication is an essential part of almost any application and getting it right ensures the security, and improved user experience (UX). In Single Page Applications (SPA), the most common strategy is the JWT-based authentication. It is based on tokens that are used to either authorize actions (access tokens), or allow reissuing new set of tokens (refresh tokens). How we store and use refresh tokens provides fertile ground for security- or UX-related issues. In this article, we discuss a strategy for storing and using refresh tokens in Next.js applications that minimizes security risks while providing the best user experience – Silent Authentication. First, we briefly describe a basic form of JWT authentication and an existing SPA built with Next.js and Ruby on Rails. Then, we introduce Silent Authentication and share how to implement it in your Next.js application. In conclusion, we share user feedback that we received after incorporating Silent Authentication into our production application.
Authentication and authorization are backbone for every major application, and designing it is always a compromise between application’s security and user convenience (or user experience, UX). For example, tightening up the password policy to require very complex passwords will reduce the chances of brute-force attacks, but UX will worsen. Thus, it’s important to balance them out, maximizing security while preserving UX.
There are a number of techniques and approaches when it comes to authentication, but in this article, we will focus on token-based strategies for Single Page Application (SPA). In particular, we will be working with Json Web Tokens (JWT), but the methods are applicable to other strategies as well if they match the specification implied in the article. The article assumes the basic understanding of JWT tokens, so if you need to refresh your memory, you may visit JWT’s documentation.
Assume that we have a Single Page Application with front-end in Next.js and back-end in Ruby on Rails. The authentication on the front-end is built using NextAuth.js. The system already supports a basic JWT authentication, i.e. back-end can issue an access token given credentials and can authorize an action when an access token is provided.
In Next.js, the application consists of two parts that are run in different environments. The server-side (later, server-side application) runs on the front-end server and its code and data are hidden from user. Another part is client-side (later, client-side application) is run inside the browser. Thus, all code and data are inherently exposed to users.
The authentication works as follows: the server-side application is responsible for obtaining and storing access tokens. So, when a user signs in, NextAuth.js sends a request to the back-end, obtains an access token, stores it, and provides it to the client-side application. The flow can be seen in the diagram below:
Then, the client-side application obtains an access token from the server-side application (using NextAuth session), and uses it when it makes requests to the back-end application. This way, the access token is available for usage in the client-side, but access to it is controlled by the server-side code. The diagram below describes loading the application and performing an action:
Even though the described approach is quite secure, it has a major UX issue: access tokens expire, and they expire quite fast. It leads to interruptions in user experience, since the expiration might happen mid-action, and the user will be required to reauthenticate before continuing their action. To mitigate this, we need to introduce a mechanism for reauthenticating user without any interaction from their side – Silent Authentication. Silent Authentication should work “under the hood” to ensure that a user has a valid access token when they need it.
The strategies for Silent Authentication can be categorized into two groups:
The former solution is simpler to implement, but it has two major flaws: a short window for refreshing the token (making it useless for cases when a user pauses the interaction for some time), and increasing the importance of access tokens. Since access tokens are exposed to browsers, they can be accessed by a user and are susceptible to Man-In-The-Middle attacks. So, if an attacker obtains an access tokens and access tokens might be exchanged for new tokens, then they can keep using the system indefinitely by making sure that the access tokens never expire.
The more robust and secure approach is introducing a separate token that will be used only for issuing new access tokens – refresh tokens. Refresh tokens are much more long lived (e.g., several months for refresh tokens vs several hours for access tokens), which allows users to avoid reauthentication even if they haven’t visited the system for a long time. The implementation of reauthentication is error-prone, and measures should be taken to reduce the attack surface.
There are several precautionary measures that can be taken to improve security:
In this article, we focus on implementing Silent Authentication using Next.js with a technology-agnostic back-end, and excluding the logic related to refresh tokens on the back-end. For the back-end, we assume that all measures described in the previous section were implemented.
Given that we have a system with basic JWT authentication and back-end that supports refresh tokens functionality, we want to introduce Silent Authentication. We have two areas to cover: storing refresh tokens and using refresh tokens. In our setup, storing refresh tokens is the easiest problem to solve – NextAuth.js encapsulates the authentication-related logic within the server-side application, allowing us to control what data the client-side application has access to that is controlled using callbacks. Generally, callbacks contain logic for authentication, exposing session data (note: session within the front-end, not with the back-end ), and providing granular control on which data is exposed to the client. Thus, all we need to do is to store the refresh token on authentication (in jwt callback) and avoid exposing it when the client application requests session information (in session callback):
async jwt({ token, user }) {
let credentials = await authenticateUser(user)
// `authenticateUser` handles sign-in request to the backend, it returns an object with the following fields:
// - `token` - access token
// - `refresh_token` - refresh token
// - `expires_at` - access token expiration datetime
if (credentials) {
token.token = credentials.token
token.refreshToken = credentials.refresh_token
token.expiresAt = credentials.expires_at
}
return token
},
// ...
async session({ session, token }) {
// token consists of `token` as access token, and `expiresAt` as expiration datetime
// ...
session.user.token = token.token
// Expiration datetime will be needed on the client to determine when to request reauthentication
if (token.expiresAt) {
session.user.expiresAt = token.expiresAt
}
// Avoid exposing refresh token via `session`
// ...
return session
},
As a result, we avoid exposing refresh tokens to the client-side application altogether while allowing the server-side application to perform reauthentication using refresh tokens.
And the next step is to avoid having expired access tokens when the front-end needs it. There are two ways to achieve it:
We decided to go with the former approach as it is simpler to implement and helps with some concurrency-related issues (e.g., performing an action that requires several parallel requests). It comes with a cost of redundant token refreshes that will be relevant for when the user doesn’t perform any actions for a long period.
The implementation is pretty straightforward. In jwt callback, reauthenticate if the access token is invalid:
async jwt({ token, user }) {
// ...
const refreshToken = token.refreshToken
const expiresAt = token.expiresAt
const expired = expiresAt ? expiresAt < Date.now() / 1000 : false
if (expired && refreshToken) {
credentials = await rotateTokens(refreshToken)
}
// ...
}
This way, every time the client requests the access token, it will be valid. And to keep the access token valid, we need to request it as soon as it expires – we can do it using a custom hook:
export function useRefreshSession() {
const { status, data, update } = useSession()
useEffect(() => {
if (status !== 'authenticated' || typeof window === 'undefined') return;
const expiresAt = data?.user.expiresAt
if (!expiresAt) return;
// dayjs library is used for working with datetime const timeLeft = dayjs(expiresAt).diff(dayjs())
if (timeLeft > 0) {
const timeout = setTimeout(() => update(), timeLeft)
return () => clearTimeout(timeout)
} else {
update()
}
}, [data, status, update])
}
This hook should be called on the top level within the necessary SessionProvider (e.g., in a top component declared in _app.tsx).
As a result, we receive a system that is described in the diagram below:
After introducing Silent Authentication, we received a lot of positive feedback from satisfied users – users were happy that they don’t have to reauthenticate constantly, especially when coming back to the system after a week or two. And, what’s also very important, we managed to avoid big security risks by never transferring refresh tokens to the client. This is how we achieved a secure but convenient authentication mechanism in our system.
Check out our newsletter