Understanding Cookies, Access & Refresh Tokens with Node.js
Emdadul Haque

Emdadul Haque @apu_emdad

About: Software Engineer | TypeScript | React | Vue | Express | NoSQL | SQL

Location:
Dhaka, Bangladesh
Joined:
Jun 14, 2025

Understanding Cookies, Access & Refresh Tokens with Node.js

Publish Date: Jun 16
0 0

Cookie:
A small piece of data stored by the browser, sent back with every request to the server. It helps the server remember information about the client, like login status.

  • When you visit a website, the server can give your browser a cookie.
  • Your browser stores it and automatically sends it back to the server every time you visit that site again.
  • Cookies are often used to remember you, like keeping you logged in or storing preferences.

Access Token:
A short-lived token sent by the server to the client after login. The client uses it to prove authentication when making API requests. It usually expires quickly for security reasons.

  • When you log in, the server gives you this token.
  • You send it with every request to protected routes (like “get user profile” or “update post”).
  • It usually expires quickly (like in 15 minutes) for security.

Refresh Token:
A longer-lived token stored safely (usually in a cookie with httpOnly) used to get a new access token when the old one expires. It helps keep the user logged in without asking for credentials repeatedly.

  • It’s longer-lasting than the access token.
  • It’s stored securely in a cookie (usually httpOnly).
  • When your access token expires, your app sends the refresh token to the server to get a new access token without logging in again.

Why Is the Refresh Token Saved in a Cookie?

The refresh token is saved in a cookie—specifically an HTTP-only cookie—to enhance security and protect against XSS (Cross-Site Scripting) attacks.

Here’s why:

  • HTTP-only flag
    When a cookie is set with httpOnly: true, it cannot be accessed by JavaScript running in the browser (e.g., document.cookie).
    This prevents attackers from stealing the refresh token even if they manage to inject malicious scripts.

  • Automatically sent with requests
    Cookies are automatically included in HTTP requests by the browser—no need to manually attach the refresh token on the frontend.
    This makes the refresh flow seamless and consistent.

  • Reduces surface area for attacks
    If you store the refresh token in localStorage or sessionStorage, it's accessible to JavaScript—making it easier for XSS attacks to extract it.
    Cookies (with proper flags like httpOnly, secure, and sameSite) reduce that risk.

  • Built-in browser handling
    The browser takes care of sending the cookie only to the origin you specify (sameSite, domain, path), giving you finer control over exposure.

Example Setup

src\app.js

//...
import cookieParser from 'cookie-parser';
import cors from 'cors';

app.use(cookieParser());
app.use(
  cors({
    origin: ['http://localhost:5173'],
    credentials: true, // ✅ this is mandatory for cookies
  }),
);
//...
Enter fullscreen mode Exit fullscreen mode

src\app\modules\Auth\auth.route.js

import { AuthControllers } from './auth.controller';
//...
router.post('/login', AuthControllers.loginUser);
router.post('/refresh-token', AuthControllers.refreshToken);
//..
Enter fullscreen mode Exit fullscreen mode

src\app\modules\Auth\auth.controller.js

import { AuthServices } from './auth.service';
//..
const loginUser = catchAsync(async (req, res) => {
  const result = await AuthServices.loginUser(req.body);
  const { refreshToken, accessToken } = result;

  res.cookie('refreshToken', refreshToken, {
    secure: config.NODE_ENV === 'production',
    httpOnly: true,
    maxAge: 7 * 24 * 60 * 60 * 1000, // cookie lasts 7 days
  });

  sendResponse(res, {
    statusCode: httpStatus.OK,
    success: true,
    message: 'User is logged in succesfully!',
    data: {
      accessToken,
    },
  });
});

const refreshToken = catchAsync(async (req, res) => {
  const { refreshToken } = req.cookies;
  const result = await AuthServices.refreshToken(refreshToken); //verifies the current refresh token. generates and returns new access token upon succesful verification

  sendResponse(res, {
    statusCode: httpStatus.OK,
    success: true,
    message: 'Access token is retrieved succesfully!',
    data: result,
  });
});

export const AuthControllers = {
  loginUser,
  changePassword,
  refreshToken,
};
//..
Enter fullscreen mode Exit fullscreen mode

Explanation of the provided example in simple terms

  1. Middleware setup (src\app.js)
app.use(cookieParser());
app.use(
  cors({
    origin: ['http://localhost:5173'],
    credentials: true, // ✅ this is mandatory for cookies
  }),
);
Enter fullscreen mode Exit fullscreen mode
  • cookieParser() lets your server read cookies sent by the browser.
  • cors() allows your frontend (http://localhost:5173) to talk to this backend, and credentials: true lets cookies be sent in cross-origin requests, which is necessary for storing refresh tokens.
  1. Routes (auth.route.js)
router.post('/login', AuthControllers.loginUser);
router.post('/refresh-token', AuthControllers.refreshToken);
Enter fullscreen mode Exit fullscreen mode
  • /login route handles user login.
  • /refresh-token route is called when the client wants a new access token using the refresh token.
  1. Login controller (auth.controller.js)
const loginUser = catchAsync(async (req, res) => {
  //takes login credential (email, password), verifies it and generates refreshToken and accessToken
  const result = await AuthServices.loginUser(req.body);
  const { refreshToken, accessToken } = result;

  res.cookie('refreshToken', refreshToken, {
    secure: config.NODE_ENV === 'production',
    httpOnly: true,
    maxAge: 7 * 24 * 60 * 60 * 1000, // cookie lasts 7 days
  });

  sendResponse(res, {
    statusCode: httpStatus.OK,
    success: true,
    message: 'User is logged in succesfully!',
    data: {
      accessToken,
    },
  });
});
Enter fullscreen mode Exit fullscreen mode
  • When a user logs in, your service returns two tokens: an accessToken and a refreshToken.
  • The refresh token is saved in an HTTP-only cookie (res.cookie(...)), so the browser stores it but JavaScript cannot read it (safer).
  • The access token is sent back in the JSON response, so the frontend can use it to authenticate API calls.
  1. Refresh token controller
const refreshToken = catchAsync(async (req, res) => {
  const { refreshToken } = req.cookies;

  // uses jwt.verify for the verification. expample - jwt.verify( refreshToken, config.jwt_refresh_secret )
  // verifies the current refresh token. generates and returns new access token upon successful verification
  const result = await AuthServices.refreshToken(refreshToken);
  sendResponse(res, {
    statusCode: httpStatus.OK,
    success: true,
    message: 'Access token is retrieved succesfully!',
    data: result,
  });
});
Enter fullscreen mode Exit fullscreen mode
  • When the frontend detects the access token expired, it calls /refresh-token.
  • The server reads the refresh token from the cookie (req.cookies).
  • It verifies the refresh token and issues a new access token.
  • The new access token is sent back to the client to continue making authenticated requests.

Summary:
Cookies store the refresh token securely. The frontend uses the access token for requests. When access token expires, frontend calls /refresh-token endpoint, sending the refresh token automatically via cookie, and gets a new access token without asking the user to log in again.

AuthService example code

import jwt from 'jsonwebtoken';
import { User } from '../user/user.model';

// util function to generate tokens
const createToken = (jwtPayload, secret, expiresIn) => {
  return jwt.sign(jwtPayload, secret, { expiresIn });
};

const loginUser = async (payload) => {
  const user = await User.findById(payload.id);
  const jwtPayload = {
    userId: user.id,
    role: user.role,
  };

  const accessToken = createToken(
    jwtPayload,
    config.jwt_access_secret,
    config.jwt_access_expires_in
  );

  const refreshToken = createToken(
    jwtPayload,
    config.jwt_refresh_secret,
    config.jwt_refresh_expires_in
  );

  return {
    accessToken,
    refreshToken,
  };
};

const refreshToken = async (token) => {
  const decoded = jwt.verify(token, config.jwt_refresh_secret);

  const jwtPayload = {
    userId: decoded.userId,
    role: decoded.role,
  };

  const accessToken = createToken(
    jwtPayload,
    config.jwt_access_secret,
    config.jwt_access_expires_in
  );

  return {
    accessToken,
  };
};

export const AuthServices = {
  loginUser,
  refreshToken,
};
Enter fullscreen mode Exit fullscreen mode

Comments 0 total

    Add comment