Skip to content

Authentication & Security

Securing your API is critical for protecting user data and preventing unauthorized access. This guide covers essential authentication and security practices for Express.js APIs, including JWT authentication, token storage strategies, role-based access control, and other security measures.

JSON Web Tokens (JWT) provide a compact, self-contained way to securely transmit information between parties as a JSON object. A JWT consists of three parts:

  1. Header: Contains the token type and signing algorithm used
  2. Payload: Contains the claims (user data and metadata)
  3. Signature: Ensures the token hasn’t been altered

JWTs are commonly used for:

  • Authentication: Verifying the identity of users
  • Authorization: Determining what resources a user can access
  • Information Exchange: Securely transmitting data between parties
  1. User logs in with credentials
  2. Server validates credentials and generates JWT
  3. JWT is sent to client
  4. Client stores JWT and sends it with subsequent requests
  5. Server validates JWT signature and grants access to protected resources

For a clean architecture, separate your authentication logic into:

  1. Controllers: Handle HTTP requests and responses
  2. Services: Contain business logic
  3. Utilities: Helper functions like JWT generation and verification
npm install jsonwebtoken bcryptjs
// utils/jwt.js
const jwt = require('jsonwebtoken');
// Generate access token
exports.generateAccessToken = (user) => {
return jwt.sign(
{
id: user.id,
email: user.email,
role: user.role
},
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' } // Short-lived access token
);
};
// Generate refresh token
exports.generateRefreshToken = (user) => {
return jwt.sign(
{ id: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' } // Longer-lived refresh token
);
};
// Verify access token
exports.verifyAccessToken = (token) => {
return jwt.verify(token, process.env.JWT_ACCESS_SECRET);
};
// Verify refresh token
exports.verifyRefreshToken = (token) => {
return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
};
// middleware/authMiddleware.js
const { StatusCodes } = require('http-status-codes');
const User = require('../models/User');
const verifyAccessToken = require('../utils/jwt');
const authMiddleware = async (req, res, next) => {
try {
// Get token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(StatusCodes.UNAUTHORIZED).json({
statusCode: StatusCodes.UNAUTHORIZED,
message: 'Authentication required',
error: 'missing_token',
path: req.originalUrl,
timestamp: new Date().toISOString()
});
}
// Extract the token
const token = authHeader.split(' ')[1];
try {
// Verify token
const decoded = verifyAccessToken(token);
// Attach user to request
req.user = decoded;
next();
} catch (tokenError) {
// Specifically identify token expiration errors
if (tokenError.name === 'TokenExpiredError') {
return res.status(StatusCodes.UNAUTHORIZED).json({
statusCode: StatusCodes.UNAUTHORIZED,
message: 'Token has expired',
error: 'token_expired',
path: req.originalUrl,
timestamp: new Date().toISOString()
});
}
// Other token verification errors
return res.status(StatusCodes.UNAUTHORIZED).json({
statusCode: StatusCodes.UNAUTHORIZED,
message: 'Invalid token',
error: 'invalid_token',
path: req.originalUrl,
timestamp: new Date().toISOString()
});
}
} catch (error) {
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
path: req.originalUrl,
timestamp: new Date().toISOString()

For a clean architecture, separate your authentication logic into controllers and services:

The service layer contains the business logic related to authentication:

// services/authService.js
const { StatusCodes } = require('http-status-codes');
const { CustomError } = require('../utils/errorUtils');
exports.registerUser = async (userData) => {
const { email, password, name } = userData;
// Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
// Throw a proper error object instead of a plain object
throw new CustomError('User already exists', StatusCodes.CONFLICT);
}
// Hash password and create user
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
const user = await User.create({
email,
password: hashedPassword,
name,
role: 'user' // Default role
});
// Generate tokens
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
return { user, accessToken, refreshToken };
};

The controller layer handles HTTP requests and responses:

// controllers/authController.js
const { StatusCodes } = require('http-status-codes');
exports.register = async (req, res) => {
try {
const authData = await authService.registerUser(req.body);
// Store refresh token in HttpOnly cookie
res.cookie('refreshToken', authData.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
// Return access token in response
return res.status(StatusCodes.CREATED).json({
statusCode: StatusCodes.CREATED,
data: {
user: {
id: authData.user.id,
email: authData.user.email,
name: authData.user.name,
role: authData.user.role
},
accessToken: authData.accessToken
},
message: 'User registered successfully'
});
} catch (error) {
// Using a custom error class allows for better error handling
return res.status(error.statusCode || StatusCodes.INTERNAL_SERVER_ERROR).json({
statusCode: error.statusCode || StatusCodes.INTERNAL_SERVER_ERROR,
message: error.message || 'Error registering user',
path: req.originalUrl,
timestamp: new Date().toISOString()
});
}
};

The token refresh mechanism allows clients to obtain a new access token when the current one expires:

  1. Client makes a request with the access token

    The client includes the access token in the Authorization header for a protected resource.

  2. Server detects an expired token

    If the token is expired, the server responds with:

    {
    "statusCode": 401,
    "message": "Token has expired",
    "error": "token_expired"
    }
  3. Client requests a new access token

    When the client receives the token_expired error, it makes a request to /api/auth/refresh-token. The refresh token is automatically included in the request cookies.

  4. Server validates the refresh token

    The server verifies the refresh token’s signature and expiration date.

  5. Server generates a new access token

    If the refresh token is valid, the server identifies the user and generates a new access token.

  6. Client receives the new access token

    The server returns the new access token in the response.

  7. Client retries the original request

    The client updates its stored access token and retries the original request that failed.

In a secure JWT implementation, two types of tokens are typically used:

  1. Access Tokens:

    • Short-lived (15-30 minutes)
    • Used to access protected resources
    • Contains user identity and permissions
    • Sent with every API request
  2. Refresh Tokens:

    • Long-lived (days or weeks)
    • Used only to obtain new access tokens
    • Should be stored more securely
    • Contains minimal information (usually just user ID)

Access tokens are commonly stored in the browser’s localStorage or memory:

Benefits of localStorage for access tokens:

  • Persists across page refreshes
  • Easy to implement
  • Available on all routes
  • Not sent automatically with every request (unlike cookies)

When to use localStorage:

  • For SPAs (Single Page Applications)
  • In low to medium security requirements
  • When implementing proper XSS protections
  • For short-lived access tokens

Refresh tokens are more sensitive and should be stored in HttpOnly cookies:

// Server sets the HttpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Cannot be accessed by JavaScript
secure: true, // Only sent over HTTPS
sameSite: 'strict', // Protection against CSRF
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});

Benefits of HttpOnly cookies for refresh tokens:

  • Cannot be accessed by JavaScript (protects against XSS)
  • Automatically sent with requests to the same domain
  • Can be secured with additional flags (secure, sameSite)
  • Browser handles cookie management
// middleware/rbac.js
exports.authorize = (...allowedRoles) => {
return (req, res, next) => {
// Check if user exists and has a role
if (!req.user || !req.user.role) {
return res.status(StatusCodes.FORBIDDEN).json({
statusCode: 403,
message: 'Forbidden: Access denied',
path: req.originalUrl
});
}
// Check if user's role is allowed
if (!allowedRoles.includes(req.user.role)) {
return res.status(StatusCodes.FORBIDDEN).json({
statusCode: 403,
message: 'Forbidden: Insufficient permissions',
path: req.originalUrl
});
}
// User has required role, proceed
next();
};
};

For more granular control, you can implement permission-based authorization instead of or in addition to role-based control.

How permissions differ from roles:

  • Roles are broad categories (admin, user, editor)
  • Permissions are specific actions (create:post, delete:user, read:report)
  • A role typically encompasses multiple permissions
  • Permissions allow for more granular access control

Implementing permissions:

  1. Define a set of permission strings (e.g., ‘read:users’, ‘create:posts’)
  2. Assign permissions to users (either directly or via roles)
  3. Check for specific permissions in your middleware
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const { authenticate } = require('../middleware/auth');
const { authorize } = require('../middleware/rbac');
// Public route
router.get('/public-data', userController.getPublicData);
// Protected route - Any authenticated user
router.get('/profile', authenticate, userController.getProfile);
// Role-based routes
router.get('/users', authenticate, authorize('admin'), userController.getAllUsers);
router.delete('/users/:id', authenticate, authorize('admin'), userController.deleteUser);
router.put('/users/:id', authenticate, authorize('admin', 'moderator'), userController.updateUser);
module.exports = router;

Rate limiting is essential to prevent abuse, brute force attacks, and to ensure fair API usage.

// Basic rate limiting implementation
const rateLimit = require('express-rate-limit');
// Create rate limiter middleware
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per IP in the windowMs timeframe
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
message: {
statusCode: 429,
message: 'Too many requests, please try again later'
}
});
// Apply to all routes
app.use(apiLimiter);
// Or apply to specific routes
app.use('/api/auth/', rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 5 requests per IP in 15 minutes
message: {
statusCode: 429,
message: 'Too many login attempts, please try again later'
}
}));

Cross-Origin Resource Sharing (CORS) is a security feature implemented by browsers to restrict requests from different origins.

// app.js
const express = require('express');
const cors = require('cors');
const app = express();
// CORS options
const corsOptions = {
origin: function (origin, callback) {
const allowedOrigins = [
'https://yourdomain.com',
'https://app.yourdomain.com'
];
// Allow requests with no origin (like mobile apps, curl, postman)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true, // To allow cookies and authentication headers
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
};
// Apply CORS middleware
app.use(cors(corsOptions));

Helmet helps secure Express apps by setting various HTTP headers to protect against common web vulnerabilities.

// Basic helmet setup
const helmet = require('helmet');
// Apply helmet middleware with default settings
app.use(helmet());

For APIs that use cookies for authentication, CSRF protection is essential:

// Basic CSRF protection setup
const { doubleCsrf } = require('csrf-csrf');
// Configure CSRF protection
const {
invalidCsrfTokenError, // Error factory for invalid tokens
generateToken, // Function to generate tokens
validateRequest, // Function to validate requests
doubleCsrfProtection, // Express middleware
} = doubleCsrf({
getSecret: () => 'your-csrf-secret',
cookieName: 'x-csrf-token',
cookieOptions: {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production'
},
size: 64, // token size
ignoredMethods: ['GET', 'HEAD', 'OPTIONS']
});
// Apply CSRF protection middleware
app.use(doubleCsrfProtection);
  1. Never store sensitive data in tokens

    • Keep tokens lightweight with minimal claims
  2. Implement proper error handling

    • Don’t expose sensitive information in error messages
  3. Audit and log authentication events

    • Track login attempts, password changes, and permission changes
  4. Rotate JWT secrets periodically

    • Use a secret management solution for production
  5. Consider using multi-factor authentication

    • For high-security applications

Storing JWTs in localStorage only

  • Vulnerable to XSS attacks
  • Better: Use HttpOnly cookies for refresh tokens

Using the same secret for access and refresh tokens

  • Compromised secret affects all tokens
  • Better: Use separate secrets for different token types

Setting very long token expiration

  • Increases security risk if token is compromised
  • Better: Short-lived access tokens with refresh capability

Not validating token contents

  • Always verify all claims in the token
  • Check for expiration, issuer, audience, etc.

Storing sensitive data in tokens

  • Tokens can be decoded even if they’re signed
  • Better: Store only identification information in tokens