Authentication and authorization are the two security boundaries every Node.js API must enforce correctly. Authentication answers "who is this user?" Authorization answers "what is this user allowed to do?" Both questions must have clear, correct answers for every request that reaches a protected endpoint. Getting either wrong produces a security gap — not a theoretical one, but the kind that gets exploited.
This article covers the authentication and authorization patterns I use in production Node.js APIs, with specific attention to the decisions that matter for security and maintainability.
JWT Authentication: Stateless and Self-Contained
JSON Web Tokens are the standard authentication mechanism for Node.js REST APIs. A JWT contains three sections: a header (algorithm and token type), a payload (claims about the user), and a signature that verifies the token was issued by the API and has not been tampered with.
The authentication flow:
- The user submits credentials (email and password) to a login endpoint
- The API verifies the credentials against the database (using bcrypt for password comparison — never store or compare plaintext passwords)
- If valid, the API generates an access token (short-lived, 15 minutes to 1 hour) and a refresh token (long-lived, 7 to 30 days)
- The access token is returned in the response body; the refresh token is set as an httpOnly cookie
- The client includes the access token in the
Authorization: Bearer <token>header for subsequent requests
JWT verification in middleware: the jsonwebtoken package verifies the signature using the secret, checks the expiry claim, and returns the decoded payload. If verification fails (invalid signature, expired token, malformed token), the middleware returns 401 before the request reaches the route handler.
Never store sensitive data in the JWT payload. The payload is base64 encoded, not encrypted. Anyone who possesses the token can decode the payload. Store only the user ID, role, and any other non-sensitive claims needed to avoid a database lookup on every request.
Refresh Token Flow: Security Without Constant Logins
Short-lived access tokens are a security best practice — if an access token is compromised, it expires quickly and the attacker's window is narrow. But users should not have to log in again every fifteen minutes. The refresh token flow solves this.
When the access token expires, the client sends the refresh token (from the httpOnly cookie) to a dedicated refresh endpoint. The API validates the refresh token against the stored hash in the database, generates a new access token, and optionally rotates the refresh token (issues a new refresh token and invalidates the old one).
Refresh token rotation is a critical security enhancement. If a refresh token is stolen and used, the original user's next refresh attempt uses the same (now-rotated) token. The API detects the reuse of an already-rotated token and revokes the entire token family for that user, forcing a full re-login. This makes refresh token theft self-reporting.
Store refresh tokens as hashed values in the database, not plaintext. If the tokens table is compromised, attackers cannot use the stored values directly. A refresh endpoint receives the plaintext token, hashes it with the same algorithm, and compares against the stored hash.
Role-Based Access Control: Roles and Permissions
Most Node.js SaaS applications need more granular access control than a simple admin/user binary. Role-based access control (RBAC) associates users with roles and roles with permissions. A request succeeds only if the authenticated user's role includes the required permission for the requested action.
The simplest RBAC model stores the user's role in the database and includes it in the JWT payload. Middleware checks the role before the route handler executes. For a flat permission model:
const requireRole = (...roles) => (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ message: 'Access denied.' });
}
next();
};
// Usage on a route
router.delete('/users/:id', authenticate, requireRole('admin'), deleteUser);
For more granular control, a permission-based model maps specific actions to specific permission strings rather than roles. The user's permission list is either embedded in the JWT or fetched from Redis on first request and cached. This allows fine-grained control like "this user can read orders but cannot delete them" without creating a new role for every combination.
Resource-Level Authorization: The Missing Layer
Role-based access control answers "can this user perform this type of action?" Resource-level authorization answers "can this user perform this action on this specific record?" The distinction is critical and the resource-level check is frequently absent in Node.js APIs.
A user authenticated as a regular account holder can view their own orders. They should not be able to view another user's orders by changing the order ID in the URL. Role-based middleware verifies the user is authenticated and has the "view-orders" permission. It does not verify that the specific order being requested belongs to the requesting user.
Resource-level authorization must be checked in the service layer, after the resource has been fetched from the database:
async function getOrder(orderId, requestingUser) {
const order = await orderRepository.findById(orderId);
if (!order) throw new NotFoundError('Order not found.');
if (order.userId !== requestingUser.id && requestingUser.role !== 'admin') {
throw new ForbiddenError('Access denied.');
}
return order;
}
This check cannot be bypassed by a clever URL or a missing middleware registration because it lives in the service layer where the business operation is performed.
OAuth2 and Third-Party Login
Many SaaS applications require social login (Google, GitHub, Microsoft) or enterprise SSO (SAML, OIDC). The Passport.js library provides strategies for hundreds of OAuth2 providers with a consistent middleware interface.
The key architecture decision with OAuth2 is what to do after the external provider authenticates the user. The recommended pattern: after Passport verifies the user with the provider, find or create a user record in your own database using the provider's user ID as a stable identifier. Generate your own JWT and session, just as you would for a password-based login. The rest of the application never needs to know the authentication provider — it works with your own JWT from that point forward.
This approach means your authorization logic, your user model, and your token management remain under your control. You are not dependent on the third-party provider's session for every API request after the initial login.
API Key Authentication for Service-to-Service Requests
SaaS products that expose a public API for third-party integrations use API key authentication rather than JWT. API keys are long, cryptographically random strings that identify the integrating application.
Store API keys hashed in the database. When a request arrives with an API key in the X-API-Key header, hash the incoming key and compare it against stored hashes. Log all API key usage including the key ID (not the key itself), the endpoint accessed, and the timestamp. This provides an audit trail and enables rate limiting per key and key revocation when needed.
If you are building a Node.js backend and need a security architecture that handles authentication and authorization correctly for production use, visit my Node.js development services page to see how I approach security in API projects.