Secure Node.js API with JWT Authentication & Refresh Tokens

AI Summary
Get a quick overview of the key points
Tap Generate Magic to let AI read for you.
Building a Node.js API is relatively easy. Securing it properly is where things get interesting.
Authentication is one of the most important parts of any modern web application. Whether you're building a MERN stack application, SaaS platform, admin dashboard, or mobile backend, you need a reliable way to verify users without sending their credentials with every request.
One popular approach is JWT (JSON Web Token) authentication combined with refresh tokens.
In this guide, we'll build a secure authentication system for a Node.js API and understand:
- What JWT authentication is
- Access tokens vs refresh tokens
- How JWT authentication works
- How to implement authentication in Node.js and Express
- How to protect API routes
- How to refresh expired access tokens
- How to securely store passwords and tokens
- Common JWT security mistakes to avoid
What Is JWT Authentication?
JWT stands for JSON Web Token.
A JWT is a compact token containing information that can be digitally signed by the server.
A typical JWT looks like:
text1xxxxx.yyyyy.zzzzz
It consists of three parts:
text1Header.Payload.Signature
For example:
json1{ 2 "sub": "123456", 3 "role": "user" 4}
The server signs this information using a secret key. When the client sends the token back, the server can verify its signature and determine whether the token is valid.
Why use JWT?
JWT authentication is popular because the server does not need to maintain a session for every authenticated request.
A typical flow looks like this:
text1User 2 ↓ 3Login 4 ↓ 5Node.js API 6 ↓ 7Verify credentials 8 ↓ 9Generate tokens 10 ↓ 11Access Token + Refresh Token 12 ↓ 13Client
The client then uses the access token when calling protected endpoints.
Access Tokens vs Refresh Tokens
A secure authentication system generally uses two different tokens.
Access Token
The access token is used to access protected API endpoints.
Example:
http1Authorization: Bearer ACCESS_TOKEN
Access tokens should usually have a short lifetime, such as 5–15 minutes.
If an attacker obtains one, the damage window is limited.
Refresh Token
A refresh token is used to obtain a new access token after the access token expires.
For example:
text1Access Token 2 ↓ 3Expires 4 ↓ 5Refresh Token 6 ↓ 7New Access Token
Refresh tokens normally have a much longer lifetime, such as several days or weeks.
The important point is:
The refresh token should not be sent with every API request.
Why Not Use One Long-Lived JWT?
You could create a JWT that remains valid for 30 days.
However, this creates a security problem.
If that token is stolen, the attacker may be able to access the account for the entire lifetime of the token.
Using short-lived access tokens reduces this exposure.
text1Short-lived Access Token 2 + 3Long-lived Refresh Token 4 = 5Better Token Lifecycle
Project Setup
Let's create a simple Express API.
Install the dependencies:
bash1npm install express jsonwebtoken bcryptjs cookie-parser dotenv
For development:
bash1npm install -D nodemon
A simple project structure could look like:
text1src/ 2├── controllers/ 3│ └── auth.controller.js 4├── middleware/ 5│ └── auth.middleware.js 6├── routes/ 7│ └── auth.routes.js 8├── utils/ 9│ └── token.js 10└── server.js
Environment Variables
Never hard-code JWT secrets directly into your source code.
Create a .env file:
env1JWT_ACCESS_SECRET=your_access_token_secret 2JWT_REFRESH_SECRET=your_refresh_token_secret 3 4ACCESS_TOKEN_EXPIRES=15m 5REFRESH_TOKEN_EXPIRES=7d
Use strong, randomly generated secrets in production.
Creating JWT Tokens
Create a utility function:
javascript1import jwt from "jsonwebtoken"; 2 3export function generateAccessToken(user) { 4 return jwt.sign( 5 { 6 sub: user._id, 7 role: user.role 8 }, 9 process.env.JWT_ACCESS_SECRET, 10 { 11 expiresIn: process.env.ACCESS_TOKEN_EXPIRES 12 } 13 ); 14} 15 16export function generateRefreshToken(user) { 17 return jwt.sign( 18 { 19 sub: user._id 20 }, 21 process.env.JWT_REFRESH_SECRET, 22 { 23 expiresIn: process.env.REFRESH_TOKEN_EXPIRES 24 } 25 ); 26}
Notice that the refresh token contains less information.
That's intentional.
JWT payloads are encoded, not encrypted, so you should never put sensitive information such as passwords, API keys, or private personal data inside them.
Hashing Passwords
Never store user passwords as plain text.
Bad:
text1password: "mypassword123"
Instead, hash the password before storing it.
Using bcrypt:
javascript1import bcrypt from "bcryptjs"; 2 3const hashedPassword = await bcrypt.hash(password, 12);
During login:
javascript1const isValid = await bcrypt.compare( 2 password, 3 user.password 4);
If isValid is true, authentication can continue.
Login Endpoint
A simplified login controller might look like this:
javascript1export async function login(req, res) { 2 const { email, password } = req.body; 3 4 const user = await User.findOne({ email }); 5 6 if (!user) { 7 return res.status(401).json({ 8 message: "Invalid email or password" 9 }); 10 } 11 12 const validPassword = await bcrypt.compare( 13 password, 14 user.password 15 ); 16 17 if (!validPassword) { 18 return res.status(401).json({ 19 message: "Invalid email or password" 20 }); 21 } 22 23 const accessToken = generateAccessToken(user); 24 const refreshToken = generateRefreshToken(user); 25 26 res.cookie("refreshToken", refreshToken, { 27 httpOnly: true, 28 secure: process.env.NODE_ENV === "production", 29 sameSite: "strict", 30 maxAge: 7 * 24 * 60 * 60 * 1000 31 }); 32 33 return res.json({ 34 accessToken 35 }); 36}
The access token is returned to the client, while the refresh token is stored in an HttpOnly cookie.
Why Use HttpOnly Cookies?
An HttpOnly cookie cannot be accessed through JavaScript running in the browser.
That means code such as:
javascript1document.cookie
cannot directly access the refresh token.
This helps reduce the impact of certain token-stealing attacks involving malicious JavaScript.
For production applications, also consider:
javascript1secure: true
when using HTTPS.
Protecting API Routes
Now we need middleware that verifies the access token.
javascript1import jwt from "jsonwebtoken"; 2 3export function authenticate(req, res, next) { 4 const authHeader = req.headers.authorization; 5 6 if (!authHeader?.startsWith("Bearer ")) { 7 return res.status(401).json({ 8 message: "Authentication required" 9 }); 10 } 11 12 const token = authHeader.split(" ")[1]; 13 14 try { 15 const payload = jwt.verify( 16 token, 17 process.env.JWT_ACCESS_SECRET 18 ); 19 20 req.user = payload; 21 22 next(); 23 } catch { 24 return res.status(401).json({ 25 message: "Invalid or expired access token" 26 }); 27 } 28}
Now we can protect routes:
javascript1router.get( 2 "/profile", 3 authenticate, 4 getProfile 5);
The request must contain:
http1Authorization: Bearer ACCESS_TOKEN
Otherwise, the server rejects it.
Refreshing an Expired Access Token
Eventually, the access token expires.
Instead of forcing the user to log in again, the client can call:
http1POST /auth/refresh
The server reads the refresh token from the HttpOnly cookie.
javascript1export async function refreshToken(req, res) { 2 const token = req.cookies.refreshToken; 3 4 if (!token) { 5 return res.status(401).json({ 6 message: "Refresh token required" 7 }); 8 } 9 10 try { 11 const payload = jwt.verify( 12 token, 13 process.env.JWT_REFRESH_SECRET 14 ); 15 16 const accessToken = generateAccessToken({ 17 _id: payload.sub 18 }); 19 20 return res.json({ 21 accessToken 22 }); 23 } catch { 24 return res.status(401).json({ 25 message: "Invalid or expired refresh token" 26 }); 27 } 28}
The client receives a new access token without requiring the user to enter their password again.
The Complete Authentication Flow
The entire process now looks like this:

This separation is the core idea behind refresh-token authentication.
Refresh Token Rotation
For more secure applications, simply keeping the same refresh token for several days isn't ideal.
A stronger approach is refresh token rotation.
The flow becomes:
text1Refresh Token A 2 ↓ 3/refresh 4 ↓ 5Invalidate A 6 ↓ 7Create Refresh Token B 8 ↓ 9Return new Access Token
If an attacker tries to reuse an already-invalidated refresh token, the server can detect suspicious activity.
For production systems, refresh tokens are often stored server-side in hashed form or associated with a session record so that they can be revoked.
Token Revocation
One limitation of JWTs is that a valid JWT normally remains valid until it expires.
Suppose a user logs out.
If an access token remains valid for another 10 minutes, simply deleting it from the browser does not automatically invalidate the token on the server.
That's why short access-token lifetimes are useful.
Refresh tokens should also be revocable.
For example, you could maintain a session collection:
text1sessions 2├── userId 3├── refreshTokenHash 4├── expiresAt 5├── createdAt 6└── revokedAt
When the user logs out:
text1Refresh Token 2 ↓ 3Find session 4 ↓ 5Mark revoked 6 ↓ 7Future refresh → rejected
Logout
A logout endpoint can clear the refresh-token cookie:
javascript1export function logout(req, res) { 2 res.clearCookie("refreshToken"); 3 4 return res.json({ 5 message: "Logged out successfully" 6 }); 7}
If refresh tokens are stored server-side, revoke the associated session as well.
Common JWT Security Mistakes
1. Storing passwords directly
Never do this:
javascript1password: password
Always hash passwords.
2. Putting sensitive data inside JWTs
Avoid:
javascript1{ 2 password: "...", 3 creditCard: "...", 4 secretKey: "..." 5}
JWT payloads can be decoded by anyone who possesses the token.
3. Using a permanent access token
Avoid:
javascript1expiresIn: "365d"
for access tokens.
Short-lived access tokens provide a smaller window of exposure if stolen.
4. Hard-coding secrets
Avoid:
javascript1jwt.sign(payload, "my-secret")
Use environment variables or a proper secrets-management system.
5. Storing refresh tokens in localStorage
For browser applications, storing long-lived authentication credentials in localStorage can increase exposure to token theft through XSS.
An HttpOnly cookie is generally a better place for a browser refresh token, with appropriate Secure, SameSite, and CSRF protections.
6. Forgetting HTTPS
Authentication credentials should never be transmitted over an insecure connection in production.
Use HTTPS.
7. Not validating input
Authentication endpoints should validate:
- Email format
- Password requirements
- Required fields
- Request body types
Libraries such as Zod, Joi, or express-validator can help.
JWT Authentication vs Sessions
JWTs aren't automatically better than traditional server-side sessions.
| Feature | JWT | Server Session |
|---|---|---|
| Server-side state | Usually minimal | Required |
| Revocation | More complicated | Straightforward |
| Horizontal scaling | Convenient | Requires shared session storage |
| Token contents | Client-readable | Usually opaque |
| Stateless API | Excellent fit | Requires session lookup |
| Implementation complexity | Moderate | Moderate |
The right choice depends on your application's architecture.
For many modern APIs, JWT access tokens combined with refresh tokens provide a practical authentication model.
Conclusion
JWT authentication becomes much more useful when you understand token lifetimes and token responsibilities, rather than simply generating a JWT after login.
A solid Node.js authentication architecture generally separates responsibilities:
- Access tokens authenticate API requests.
- Refresh tokens maintain long-lived sessions.
- Password hashing protects stored credentials.
- HttpOnly cookies can protect browser refresh tokens from direct JavaScript access.
- Short expiration times reduce the impact of stolen access tokens.
- Token rotation and revocation provide stronger control over long-lived sessions.
💻 happy coding...