How to store a JWT token inside an HTTP only cookie?
Solution 1:
Dealing with cookies has their fair share of subtleties, but at a high level a cookie is a piece of data that your web server can set, that will be then stored by the user's web browser and sent back to the server on any future requests that browser makes to the same server as long as the cookie is valid and applicable to the request being made.
(this is why you'll no longer need to use the Angular interceptors, because it's the browser itself that ensures the cookie is sent)
Besides some specials flag options, like the HTTP only, at a higher level you can set cookies to be associated with a given domain and path. For example, your server could set a cookie in such way that it would only be later sent by the browser to requests made under the /api
path.
To sum it up, cookies are a state management mechanism for HTTP, see the associated RFC 2617 for more details.
In contrast, a JWT is just some data that has a well-know representation and follows some conventions. More specifically, a JWT is composed of a header, payload and signature sections and is generally advised to keep the size of the payload small for most of the JWT use cases. See Get Started with JSON Web Tokens for more details.
If you go through the previous article you'll notice that the final representation of a JWT is three Base64url encoded strings separated by dots. This is specially of interest because it means a JWT is well-suited to be used within HTTP, including as the value of a cookie.
One thing to have in mind is that by the specification you are only guaranteed that a browser will support a cookie up to 4096 bytes per cookie (as measured by the sum of the length of the cookie's name, value, and attributes). Unless you're storing way to much data in the token you should not have an issue, but it's always something to consider. Yes, you can also break a JWT token into multiple cookies, but things start to get more complex.
Additionally, cookies have their notion of expiration, so have that in mind also because the JWT itself, when used within the scope of authentication will also have thei own notion of expiration.
Finally, I just want to address some of your concerns about storing the JWT in localStorage
/sessionStorage
. You're correct that if you do it you have to understand its implication, for example, any Javascript code within the domain for which the storage is associated will be able to read the token. However, HTTP only cookies are also not a silver-bullet. I would give the following article a read: Cookies vs Tokens: The Definitive Guide.
It focuses on the differences between the traditional session identifier cookies vs the token-based (JWT) authentication systems, the section named Where to Store Tokens? warrants a read as it tackles the security related aspects of storage.
A summary for the TL:DR folks:
Two of the most common attack vectors facing websites are Cross Site Scripting (XSS) and Cross Site Request Forgery (XSRF or CSRF). Cross Site Scripting) attacks occur when an outside entity is able to execute code within your website or app. (...)
If an attacker can execute code on your domain, your JWT tokens (in local storage) are vulnerable. (...)
Cross Site Request Forgery attacks are not an issue if you are using JWT with local storage. On the other hand, if your use case requires you to store the JWT in a cookie, you will need to protect against XSRF.
(emphasis is mine)
Solution 2:
Basically, I save access_token(jwt) in a refresh token object stored in the database when the user logs in. see an example of the saved object below;
const newToken = new RefreshToken({
issuedUtc: moment().unix(), /* Current unix date & time */
expiresUtc: moment().add(4, "days").unix(), /* Current unix date&time + 4 days */
token: refreshToken, /* Generate random token */
user: data.id, /* user id */
/* Signing the access Token */
access_token: jwt.sign(
{ sub: data.id, user: userWithoutHash },
Config.secret,
{
issuer: "http://localhost:3000",
expiresIn: "30m", // Expires in 30 minutes
}
),
});
The generated and saved rand token is then sent as httpOnly cookie to the browser;
res.cookie("refreshToken", newToken.token, {
httpOnly: true,
sameSite: "strict",
});
Since the browser sends the cookie for every request all that is left is to use middleware on protected routes, retrieve the token from the cookie, verify if it is exists by looking for it in the database, check if it has not expired, try to verify the access token saved in the database for that refresh token, if it is expired then sign new jwt and update the refresh token in the database then allow the user to proceed to protected route, if it is valid simply allow the user to proceed to protected route. If the refresh token has expired, redirect the user to the login page, and lastly if no refresh token is received also redirect the user to the login page.
var cookie = await getcookie(req); // get the cookie as js object using my custom helper function
/* Check if refresh token was received */
if (cookie.refreshToken) {
/* Check find the refresh token object in the database */
var refreshToken = await RefreshToken.findOne({
token: cookie.refreshToken,
});
/* Check if the refresh token is still valid using expiry date */
if (moment.unix(refreshToken.expiresIn) > moment.now()) {
/* If the condition is fulfilled try to verify the access token using jwt */
jwt.verify(refreshToken.access_token, Config.secret, async (err, result) => {
/* in callback check for error */
if (err) {
/* If error this means the access_token is expired, so find and update the user's refresh token with a newly signed access token */
await RefreshToken.findByIdAndUpdate(refreshToken.id, {
access_token: jwt.sign(
{ sub: result.id, user: result.user },
Config.secret,
{
issuer: "http://localhost:3000",
expiresIn: "30m", // Expires in 30 minutes
}
),
});
/* Proceed to save the user in a local variable then call next */
res.locals.user = result.user;
return next();
}
/* If no error proceed by saving the user in a local variable then call next */
res.locals.user = result.user;
return next();
});
} else {
/* If the refresh token is expired, then redirect to log in */
return res.status(401).redirect('/login');
}
} else {
/* If no refresh token is provided, then redirect to log in */
return res.status(401).redirect('/login');
}
This is something I came up with myself so I can't say it is full proof but since httpOnly cookie cannot be accessed in the DOM, running malicious script in the DOM can't access the refresh token, and even if the refresh token somehow falls in the hand of bad guys then it will be useless because it does not hold any information at all until it gets to the server. So as long the right cors header is set on the server it is highly unlikely that any information can be leaked using the refresh token.