Part 3/3: How to Implement Refresh Tokens through Http-Only Cookie in NestJS and React

zenstok - Sep 2 - - Dev Community

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;
};
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
  };
}
Enter fullscreen mode Exit fullscreen mode

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),
]),
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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;
  };
Enter fullscreen mode Exit fullscreen mode
  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;
  };
Enter fullscreen mode Exit fullscreen mode

Define Clear Auth Cookie API Call:

Implement the clearAuthCookie API call:

   const clearAuthCookie = () => {
     return sendRequest(
       ApiMethod.POST,
       routes.auth.clearAuthCookie,
       undefined,
       undefined,
       { credentials: "include" },
     );
   };
Enter fullscreen mode Exit fullscreen mode

Call clearAuthCookie on Logout:

Ensure the auth cookie is cleared on logout:

   const logout = () => {
     AuthClientStore.removeAccessToken();
     return clearAuthCookie();
   };
Enter fullscreen mode Exit fullscreen mode

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!

. . . . . . .
Terabox Video Player