Express Refresh and Access token Authentication
Learn how to use refresh tokens and access tokens to add authentication and authorization to an Express app. We will use the libraries jsonwebtoken and cookie-parser and will also go over the security benefits of refresh and access tokens.
Table of Contents 📖
- Refresh Token and Access Token Overview
- Displaying a Login Page
- Handling User Login
- Handling the Refresh Token
- Creating Authorization Middleware
- Handling Authenticated Route
- Demonstration
Refresh Token and Access Token Overview
When a user logs in with their credentials to a website that uses token authorization, they will most likely receive two tokens: an access token and a refresh token. The access token has a short lifespan and is used to identify and authorize the user in subsequent requests. The refresh token has a long lifespan and is used to obtain a new access token when the access token expires.
Having both a refresh and access token creates a better user experience and increases security for the following reasons:
- The user does not have to log back in if the access token expires as the refresh token can perform a silent login and get a new access token
- Access token has a short lifespan so if it is stolen the attacker has a small window of access
- Refresh token is only sent over the wire when a new access token is required
- Refresh token can be revoked if it has been compromised
Displaying a Login Page
To begin, lets create a route that serves up a login form if the user does not have an access token. If they do have a valid access token we will redirect them to their profile route.
/**
* Home page for logging in
*/
app.get('/', (req, res) => {
const accessToken = req.cookies[MY_ACCESS_TOKEN];
if (!accessToken) {
return res.status(200).sendFile(path.resolve(__dirname, '../', 'public', 'index.html'));
}
res.redirect('/profile');
});
Note that if this access token is fake or modified it will be caught by the authorization middleware that protects the profile route.
Handling User Login
Next lets handle this POST payload. If the login is successful we create both an access token and refresh token for the user and redirect them to their profile page. If the login is unsuccessful we redirect them back to the login form.
// Keeps track of refresh tokens belonging to each user
const refreshTokenMap = new Map<string, string[]>();
/**
* Handle the login payload
*/
app.post('/login', (req, res) => {
const {username, password} = req.body;
// Login successful
if (username === 'WittCode' && password === 'subscribe') {
// Generate access and refresh tokens
const accessToken = jwt.sign({username}, SECRET, {expiresIn: '1h'});
const refreshToken = jwt.sign({username}, SECRET, {expiresIn: '30d'});
// Keep track of a user's refresh tokens so if they get compromised we can remove them
const refreshTokens = refreshTokenMap.get(username) ?? [];
refreshTokens.push(refreshToken);
refreshTokenMap.set(username, refreshTokens);
// Create access token cookie
res.cookie(MY_ACCESS_TOKEN, accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
// 1 hr
maxAge: 60 * 60 * 1000
});
// Create refresh token cookie that is only sent in requests to /refresh
res.cookie(MY_REFRESH_TOKEN, refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/refresh',
// 30 days
maxAge: 30 * 24 * 60 * 60 * 1000
});
// Password and username correct redirect to profile
console.log('Correct username and password');
return res.redirect('/profile');
} else {
// Wrong password and username
console.log('Incorrect username and password');
return res.redirect('/');
}
});
Here are the steps in order
- Create a map to hold the refresh tokens that belong to each user. This is so they can be revoked if they are compromised. In the real world, the refresh tokens should be hashed and stored in a database.
- Get the username and password from the request payload, if they are equal to WittCode and subscribe then the login was successful.
- If the username and password were wrong then we redirect the user to the home page.
- Upon successfull login, create both the access token and refresh token using the jsonwebtoken library. The access token has a short lifespan while the refresh token has a long lifespan.
- Add the refresh token to the list of tokens belonging to that user. If one is compromised then we can blacklist it.
- Set a cookie on the response containing the access token. Add appropriate restrictions.
- Set a cookie on the response containing the refresh token. Add appropriate restrictions. An important one is setting the cookie to only be sent on requests to /refresh.
- If everything was successful send them to the homepage.
Handling the Refresh Token
Now lets handle the refresh token route. We want to place this route before our authorization middleware as we want to be able to obtain a new access token without one being present in the request. Here we will check the validity of the refresh token and use it to generate a new access token.
/**
* Handle the refresh token. Needs to be before the authorization
* middleware so we can get a new access token when the refresh token
* has expired.
*/
app.get('/refresh', (req, res) => {
console.log('Obtaining new access token with the refresh token');
// Get the refresh token, will only be present on /refresh call
const refreshToken = req.cookies[MY_REFRESH_TOKEN];
// Refresh token is not present
if (!refreshToken) {
console.log('Refresh token not found, sending them to login page');
return res.redirect('/');
}
// Create a new access token and set it on the cookie
try {
const {username} = jwt.verify(refreshToken, SECRET) as {username: string};
const accessToken = jwt.sign({username}, SECRET, {expiresIn: '1h'});
res.cookie(MY_ACCESS_TOKEN, accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
// 1 hr
maxAge: 60 * 60 * 1000
});
console.log('New access token generated');
return res.status(200).send('<h1>New access token generated!</h1>');
// Invalid refreshToken, clear cookie and send to home page
} catch (err) {
console.log('Invalid refresh token');
res.clearCookie(MY_REFRESH_TOKEN);
return res.redirect('/');
}
});
Here are the steps in order
- Get the refresh token from the request cookies.
- If no refresh token is present then send them to the login page.
- If there is a refresh token then verify it.
- If the token is invalid then clear the refresh token and send the user to the login page.
- If the refresh token is valid then create a new access token and send back a 200 response. The client should have logic implemented to call this route whenever they receive a 401 unauthorized from the server. Then after they receive a new access token, remake the call that gave them a 401.
Creating Authorization Middleware
Now lets add some global authorization middleware. This middleware will check to see if the request contains a valid access token stored in a cookie.
/**
* Authorization middleware
*/
app.use((req: Request, res: Response, next) => {
console.log('Authenticating request');
// Attempt to validate access token. If token is valid, send to next middleware in stack
const accessToken = req.cookies[MY_ACCESS_TOKEN];
// No access token provided
if (!accessToken) {
console.log('No access token provided');
return res.redirect('/');
}
// Validate access token
try {
const user = jwt.verify(accessToken, SECRET);
res.locals.user = user;
console.log('Access token valid');
return next();
// Token is no longer valid, clear token and attempt to get a new token with the refresh token
} catch (err) {
console.log('Access token invalid, need to get a new one');
res.clearCookie(MY_ACCESS_TOKEN);
return res.status(401).send('<h1>Unauthorized</h1>');
}
});
Here are the steps in order
- Get the access token from the request cookie.
- If there is no access token then send the user to the login form.
- If there is an access token then check if it is valid. It will not be valid if it has been tampered, expired, etc.
- If the token is valid then attach the JWT payload to the request/response lifecycle using the res.locals property and go to the next middleware in the stack.
- If the token is invalid then clear the cookie containing the token and send back an unauthorized message. The client should implement logic to call the refresh route with the refresh token to get a new access token.
Handling Authenticated Route
Now lets simply add a route that requires authorization to access.
/**
* Protect route with profile information
*/
app.get('/profile', (req, res) => {
const {username} = res.locals.user;
return res.send(`<h1>Hello ${username}!</h1>`);
});
Here we get the username attached to the request/response lifecycle and then return it in an H1 tag.
Demonstration
Now lets demonstrate this application in action.
- First we log in with the username WittCode and the password subscribe. This sets an access token and refresh token on the client and gives us access to the /profile route.
- We then delete the access token which causes us to be sent to the login form.
- To get a new access token we go to the /refresh route and use our refresh token.
- Now we are authenticated again and can access the /profile route.