Hello everyone,
Welcome to the final episode of our three-part series on token management in a NestJS + React application. In the first two posts, we walked through the process of implementing refresh token logic by storing tokens in local storage. Today, we’ll advance to a more secure method by implementing HTTP-only cookies for refresh tokens.
Why Use HTTP-Only Cookies?
HTTP-only cookies allow us to store sensitive data, such as refresh tokens, in a way that cannot be accessed by JavaScript. This means that even if there are vulnerabilities in your code or third-party libraries, a hacker won't be able to retrieve the refresh token.
However, even with HTTP-only cookies, there's still a risk of requests being made on behalf of the user through XSS attacks. The key advantage is that hackers will not be able to access the value of the refresh token directly; they would need to execute targeted XSS attacks on the application's endpoints, which requires prior knowledge of the system.
Why Not Store Both Access and Refresh Tokens in HTTP-Only Cookies?
By keeping the access token out of cookies, we protect against CSRF (Cross-Site Request Forgery) attacks. To mitigate XSS attacks, we set a short expiry time on the access token, limiting the damage even if it’s compromised.
Should We Worry About CSRF on the /refresh-tokens Endpoint?
Not really. Since this endpoint doesn’t compromise the system or user data, CSRF attacks here are less of a concern.
Enhancing Security: Hashing Refresh Tokens
To further improve security, we also hash the refresh tokens in the database. We use a hashing algorithm without salt, allowing us to reproduce the same hash for previously used tokens to check if they have been blacklisted. With this improvement, in case of database leaks, the hacker will not be able to read any refresh tokens!
Getting Started
To begin, ensure you've completed the guides in Part 1 and Part 2 for app installation. Afterward, follow these steps to implement the necessary changes.
If you want to jump straight into the code, you can check out the repository here on the "part-3" branch.
Step 1: Define Cookie Configuration and Helper Function
First, set up a cookie configuration and create a helper function to extract the refresh token from cookies:
import { Request } from 'express';
export const cookieConfig = {
refreshToken: {
name: 'refreshToken',
options: {
path: '/', // For production, use '/auth/api/refresh-tokens'. We use '/' for localhost in order to work on Chrome.
httpOnly: true,
sameSite: 'strict' as 'strict',
secure: true,
maxAge: 1000 * 60 * 60 * 24 * 30, // 30 days; must match Refresh JWT expiration.
},
},
};
export const extractRefreshTokenFromCookies = (req: Request) => {
const cookies = req.headers.cookie?.split('; ');
if (!cookies?.length) {
return null;
}
const refreshTokenCookie = cookies.find((cookie) =>
cookie.startsWith(`${cookieConfig.refreshToken.name}=`)
);
if (!refreshTokenCookie) {
return null;
}
return refreshTokenCookie.split('=')[1] as string;
};
Step 2: Enable CORS to Accept Cookies
Ensure the CORS policy allows credentials so that our backend can receive cookies from the frontend:
app.enableCors({ origin: true, credentials: true }); // Set the correct origin for production.
Step 3: Update the Token Generation Method
Modify the generateTokenPair
method to set the refresh token as an HTTP-only cookie whenever it's called:
async generateTokenPair(
user: Express.User,
res: Response,
currentRefreshToken?: string,
currentRefreshTokenExpiresAt?: Date,
) {
const payload = { sub: user.id, role: user.role };
res.cookie(
cookieConfig.refreshToken.name,
await this.generateRefreshToken(
user,
currentRefreshToken,
currentRefreshTokenExpiresAt,
),
{
...cookieConfig.refreshToken.options,
},
);
return {
access_token: this.jwtService.sign(payload), // JWT module is configured in auth.module.ts for access token.
};
}
Step 4: Update JWT Strategy to Use Cookies
Modify the refresh JWT strategy to retrieve the token from cookies instead of using bearer token authentication:
jwtFromRequest: ExtractJwt.fromExtractors([
(req: Request) => extractRefreshTokenFromCookies(req),
]),
Step 5: Adjust Token Handling and Add Cookie Clearing Endpoint
When calling the /refresh-tokens
endpoint, the token should now be extracted from the HTTP-only cookie, replacing the previous method of using Bearer authorization.
Additionally, we must implement a /clear-auth-cookie
endpoint to remove the cookie. This endpoint should be invoked during the logout action on the frontend. The reason for this is that HTTP-only cookies cannot be cleared programatically from the frontend, so this ensures the user is properly logged out.
@Public()
@UseGuards(JwtRefreshAuthGuard)
@Post('refresh-tokens')
refreshTokens(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
if (!req.user) {
throw new InternalServerErrorException();
}
return this.authRefreshTokenService.generateTokenPair(
(req.user as any).attributes,
res,
extractRefreshTokenFromCookies(req) as string,
(req.user as any).refreshTokenExpiresAt,
);
}
@Public()
@Post('clear-auth-cookie')
clearAuthCookie(@Res({ passthrough: true }) res: Response) {
res.clearCookie(cookieConfig.refreshToken.name);
}
Step 6: Update Frontend Logic
Now regarding the frontend project, we will actually simplify the logic a little bit.
Remove Refresh Token from Local Storage:
Clear any references to the refresh token in the AuthClientStore
class:
const ACCESS_TOKEN_KEY = "rabbit.byte.club.access.token";
class AuthClientStore {
static getAccessToken() {
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
static setAccessToken(token: string) {
localStorage.setItem(ACCESS_TOKEN_KEY, token);
}
static removeAccessToken(): void {
localStorage.removeItem(ACCESS_TOKEN_KEY);
}
}
export default AuthClientStore;
Update Request Methods:
Remove the refreshToken
variable from the sendProtectedRequest
method, as it's no longer needed.
const sendProtectedRequest = (
method: ApiMethod,
path: string,
// eslint-disable-next-line
body?: any,
init?: RequestInit,
) => {
const authToken = AuthClientStore.getAccessToken();
if (!authToken) {
throw new Error("No auth token found");
}
return sendRequest(method, path, body, authToken, init);
};
Include Credentials in Requests:
Very important: Update the login
and refresh token
API integration methods to include credentials, ensuring that cookies can be set and sent correctly.
const login = async (email: string, password: string) => {
const response = await sendRequest(
ApiMethod.POST,
routes.auth.login,
{
email,
password,
},
undefined,
{ credentials: "include" }, // Required update
);
AuthClientStore.setAccessToken(response.access_token);
return response;
};
const refreshTokens = async () => {
clearTimeout(timeout);
if (!debouncedPromise) {
debouncedPromise = new Promise((resolve, reject) => {
debouncedResolve = resolve;
debouncedReject = reject;
});
}
timeout = setTimeout(() => {
const executeLogic = async () => {
const response = await sendRequest(
ApiMethod.POST,
routes.auth.refreshTokens,
undefined,
undefined,
{ credentials: "include" }, // Required update
);
AuthClientStore.setAccessToken(response.access_token);
};
executeLogic().then(debouncedResolve).catch(debouncedReject);
debouncedPromise = null;
}, 200);
return debouncedPromise;
};
Define Clear Auth Cookie API Call:
Implement the clearAuthCookie
API call:
const clearAuthCookie = () => {
return sendRequest(
ApiMethod.POST,
routes.auth.clearAuthCookie,
undefined,
undefined,
{ credentials: "include" },
);
};
Call clearAuthCookie
on Logout:
Ensure the auth cookie is cleared on logout:
const logout = () => {
AuthClientStore.removeAccessToken();
return clearAuthCookie();
};
And that's it! With these steps, the refresh token logic is now integrated into an HTTP-only cookie. You can proceed to Part 2 of the tutorial to test the solution.
If you'd like me to cover more interesting topics about the node.js ecosystem, feel free to leave your suggestions in the comments section. Don't forget to subscribe to my newsletter on rabbitbyte.club for updates!
Top comments (0)