Report this

What is the reason for this report?

How To Use JSON Web Tokens (JWTs) in Express.js

Updated on November 18, 2025
Danny DenenbergManikandan Kurup

By Danny Denenberg and Manikandan Kurup

How To Use JSON Web Tokens (JWTs) in Express.js

Introduction

JSON Web Token authentication has become a common choice for modern APIs because it allows servers to verify requests without storing session data. As applications grow and rely on distributed services, the ability to authenticate users through self-contained, signed tokens provides a straightforward way to maintain identity across different parts of a system. Express.js remains one of the most widely adopted frameworks in the Node.js ecosystem, making it a practical environment for learning how JWT-based authentication works in real projects.

This article walks you through the process of building a complete JWT authentication flow in Express.js, covering token structure, signing and verification, middleware design, and the separation between access and refresh tokens. You will learn how to set up an Express project, generate and validate tokens, protect routes, manage refresh flows, and apply security practices that support real-world applications. Advanced topics such as asymmetric signing, JWKS-based key rotation, and alternative token formats are included to ensure that your implementation aligns with current standards and remains adaptable for future requirements.

Deploy your Node applications from GitHub using DigitalOcean App Platform. Let DigitalOcean focus on scaling your app.

Key Takeaways:

  • JWTs enable stateless authentication, allowing servers to validate requests without maintaining session data, which fits well with distributed and horizontally scaled systems.
  • A JWT consists of a header, payload, and signature, and only ensures integrity. The payload is readable and should never contain sensitive information.
  • Access tokens and refresh tokens serve different purposes: access tokens are short-lived for request authorization, while refresh tokens allow long-lived sessions without repeated logins.
  • Refresh tokens should be stored in HttpOnly Secure cookies, not in localStorage, to reduce exposure to script-based attacks.
  • Token verification belongs in middleware, where Express can validate signatures, enforce expiration, and attach user claims to req.user for downstream authorization.
  • Role-based authorization is straightforward with JWTs, since role or permission claims can be included in the token payload and checked in dedicated middleware.
  • Proper key management is essential, whether using strong symmetric secrets (HS256) or adopting asymmetric signing (RS256/ES256) for distributed verification.
  • Refresh token rotation and revocation protect against replay attacks, using either token versioning or a server-side revocation list.
  • Key rotation is easier with JWKS endpoints, allowing public keys to be updated without redeploying services that verify tokens.
  • Standards such as OAuth 2.1, OpenID Connect, and PASETO complement or extend JWT usage, providing structured ways to handle authorization, identity, and cryptographic safety in modern systems.

Prerequisites

To follow along with this article, you will need the following installed on your machine:

What is a JWT?

A JSON Web Token (JWT) is a compact, URL-safe token format used to represent claims in a signed JSON payload. Its structure allows the server to verify a token without storing user state. This characteristic helped make JWTs popular as RESTful APIs gained adoption. REST encourages stateless communication, yet traditional session-based authentication depends on server-side storage. As services became distributed and APIs were consumed by browsers, mobile clients, and third-party integrations, maintaining session data across nodes created operational challenges. JWTs avoided this issue. Any service with access to the signing key could validate a token, which aligned well with horizontally scaled and microservice-based architectures.

Developers today have several alternatives designed to address common implementation mistakes or provide simpler validation patterns. PASETO offers strict cryptographic defaults and eliminates insecure algorithm options. OAuth 2.1 access tokens often use opaque token formats that are validated through introspection rather than client-side signature checks, centralizing control at the authorization server. Traditional session cookies remain effective for browser-based applications where server-side state is acceptable.

Even with these alternatives, JWTs remain common in Express.js applications. Express became popular during the rise of single-page applications and early OAuth 2.0 integrations, where JWTs were widely adopted for API authentication. As a result, the ecosystem built extensive middleware, community examples, and stable libraries such as jsonwebtoken and jose. This long-standing support makes JWTs a straightforward choice for many teams working in the Express.js environment.

Setting Up an Express.js Project (with ES Modules)

To build a JWT-based authentication flow in Express.js, you will first set up a project that follows current Node.js conventions. Modern Node releases support ES modules natively, so our setup will reflect this standard. The structure outlined here keeps the application organized and ready for routing, middleware, and configuration files that will be added later.

Initializing the Project

Start by creating a new directory for your API and initializing it with either npm:

  1. npm init -y

This command generates a package.json file that will hold your project’s metadata and dependencies.

Enabling ES Modules

To use ES module syntax, add the following entry to package.json:

"type": "module"

This instructs Node.js to interpret .js files as ES modules, allowing you to use import and export statements throughout the project.

Installing Dependencies

Next, install the required packages:

  1. npm install express dotenv jsonwebtoken cookie-parser
  2. ### Recommended Folder Structure
  3. A clean project layout makes future modules easier to manage. The following structure works well for an authentication-focused API:
  4. ```bash
  5. src/
  6. app.js
  7. routes/
  8. controllers/
  9. middleware/
  10. config/

This setup separates route definitions, controller logic, middleware functions, and configuration files. Each directory will take on more responsibility as the project grows.

Initial Application Setup

Once the folders are in place, create src/app.js and add the initial Express setup:

import express from 'express';
import dotenv from 'dotenv';

dotenv.config();

const app = express();
app.use(express.json());

app.listen(3000, () => console.log('Server running on port 3000'));

This file loads environment variables, initializes Express, enables JSON parsing, and starts the server on port 3000. With this foundation in place, the project is ready for routing and authentication middleware.

Understanding JWTs: Structure and Use Cases

Before writing code that issues and verifies JWTs, it is useful to understand how a JWT is constructed and why it became a common choice for stateless authentication. A JWT is a compact string composed of three Base64URL-encoded segments: a header, a payload, and a signature. These segments are joined with periods, creating a format that can be transported safely in HTTP headers, query parameters, or cookies. Although JWTs often appear opaque, each part has a specific purpose that contributes to how the token is validated and trusted.

The header identifies the token type and specifies the signing algorithm. In most implementations, this includes fields such as alg (for example, HS256 or RS256) and typ to indicate that the token is a JWT. The payload contains claims, which are key-value pairs describing the token’s subject and its expected behavior. Common claims include the subject identifier (sub), the time the token was issued (iat), and the expiration timestamp (exp). Since the payload is only encoded, not encrypted, it is readable by anyone who possesses the token. This is why sensitive values, such as passwords or personal information, should never appear in a JWT. The final part, the signature, ensures the integrity of the token. It is calculated by combining the encoded header and payload with the server’s secret or private key. When a client sends a JWT, the server recomputes the signature. If the calculated value matches the token’s signature, the server can trust that the token has not been altered.

A decoded JWT highlights the structure clearly:

Segment Example Content
Header { "alg": "HS256", "typ": "JWT" }
Payload { "sub": "12345", "role": "user", "exp": 1735689600 }
Signature A cryptographic hash over the header and payload

These three components work together to allow secure, stateless authentication across services.

Common Use Cases

JWTs support a range of patterns that are common in distributed and API-driven systems.

API Authentication

JWTs are frequently used to authenticate clients calling an API. After a user signs in, the server issues a token that includes claims such as the user identifier and expiration time. The client attaches this token to each request using the Authorization: Bearer <token> header. The server verifies the signature and extracts the claims to determine who is making the request and whether the token is still valid.

Single Sign-On (SSO)

Multiple applications can rely on a single token issuer to authenticate users. Because the token contains all the information required for validation, each application can verify it locally without querying a central session store. This makes it possible for users to authenticate once and access multiple services without repeated logins.

Stateless Session Management

Traditional session-based authentication requires storing session data on the server. JWTs eliminate this requirement. Since the token contains the necessary claims, the server does not need to maintain session state. This pattern simplifies scaling across containers or nodes, as any instance can validate the token independently.

These use cases explain why JWTs continue to be a practical choice for API authentication, especially in environments where services must scale horizontally or communicate across different components. With this foundation in place, you can move confidently into implementing JWT authentication in Express.js.

Generating JWTs

Before creating authentication routes, it is important to understand how JWTs are generated and which tools are best suited for the task. In Node.js, JWTs are commonly produced using either the long-standing jsonwebtoken package or the newer jose library. Both can issue signed tokens, but they differ in syntax, maintenance status, and support for modern cryptographic standards. This section explains how each library works, how to sign a basic access token, and how to manage your signing key securely using environment variables.

Generating JWTs with jsonwebtoken

The jsonwebtoken package has existed in the Node.js ecosystem for many years and remains widely used in established Express.js applications. It supports symmetric signing algorithms such as HS256 and provides a straightforward method for creating tokens. Although the library pre-dates native ES module support in Node.js, it can still be used with modern syntax.

Here’s a simple example:

import jwt from 'jsonwebtoken';

const payload = { sub: '12345', role: 'user' };
const secret = process.env.JWT_SECRET;

const token = jwt.sign(payload, secret, {
  expiresIn: '15m'
});

console.log('Access Token:', token);

In this example, the payload holds the subject identifier and role. The token is signed with a secret key, and the expiration time is set to fifteen minutes. The server will later verify the token by recomputing the signature using the same key.

Generating JWTs with jose

The jose library is a more recent toolkit designed with modern JavaScript features in mind. It provides complete ES module compatibility, supports a broader range of algorithms, and offers built-in handling for both symmetric secrets and asymmetric key pairs. The API follows a builder pattern, which makes it easier to express claims, headers, and expiration details in a structured manner.

The following example is similar to the previous one except we use jose:

import { SignJWT } from 'jose';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

const token = await new SignJWT({ sub: '12345', role: 'user' })
  .setProtectedHeader({ alg: 'HS256' })
  .setIssuedAt()
  .setExpirationTime('15m')
  .sign(secret);

console.log('Access Token:', token);

This pattern highlights each step: defining claims, setting headers, configuring timestamps, and signing the token with a key. For projects that require public/private key signing or plan to rotate keys, jose provides first-class support.

Why Many Teams Prefer jose

While both libraries can issue valid tokens, many teams favor jose because it reflects the direction of modern Node.js development. It provides complete ES module support without additional configuration, offers extensive cryptographic capabilities for both symmetric and asymmetric algorithms, and maintains safer defaults. Its builder-style API helps avoid common mistakes such as missing headers or incorrectly formatted claims. In addition, the library receives frequent updates and active community support, making it a strong choice for production systems that require consistent, long-term cryptographic maintenance.

Signing an Access Token

When signing an access token, it is important to include a consistent set of claims that define how the token should be interpreted by the server. Every token should contain a sub claim, which identifies the user or entity the token represents. This value allows the server to associate an incoming request with an authenticated principal and serves as the foundation for authorization decisions.

The iat claim records the timestamp when the token was issued. This helps the server determine how long the token has been active and can support additional checks such as token freshness requirements. Alongside iat, the exp claim sets the precise moment when the token becomes invalid. Including an expiration time limits how long a token can be used, which reduces risk if a token is ever intercepted or leaked.

Many applications add custom claims to simplify authorization workflows. These fields may contain a user’s role, a list of permissions, or application-specific metadata that influences access control decisions. These values should be limited to information that the server needs to evaluate the request. Because the JWT payload is only encoded and not encrypted, sensitive or private information should never be placed inside the token.

Together, these claims create the core metadata that allows a server to authenticate and authorize requests without maintaining session state.

Storing Your Secret Key Securely

JWT signing keys should never be hardcoded into the application. Instead, use an environment variable stored in a .env file to ensure the key remains separate from the source code:

.env
JWT_SECRET="a-strong-secret-key"

Load the variable into your application with:

import dotenv from 'dotenv';
dotenv.config();

const secret = process.env.JWT_SECRET;

This approach keeps sensitive values out of version control and allows development, test, and production environments to use their own independent signing keys.

Verifying JWTs with Middleware

After setting up token generation, the next step is enforcing authentication on protected routes. Express.js applications typically achieve this by placing verification logic inside middleware. Middleware runs before a route handler and can either allow the request to proceed or stop it based on validation rules. By handling verification in one location, your application avoids duplicating logic and ensures that every protected endpoint follows the same authentication process.

Building the authenticateToken Middleware

A standard pattern is to read the token from the Authorization header, where clients send the value in the form Bearer <token>. This header is preferred because it keeps authentication credentials separate from route parameters and request bodies, and it aligns with standard HTTP practices. The middleware extracts the token, verifies it with the signing key, and exposes the decoded payload for downstream handlers.

import jwt from 'jsonwebtoken';

export function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ message: 'Token missing' });
  }

  jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
    if (err) {
      return res.status(403).json({ message: 'Invalid or expired token' });
    }

    req.user = decoded;
    next();
  });
}

In this implementation, the middleware looks for the Authorization header, extracts the token, and then calls jwt.verify(). Verification includes checking the signature, ensuring the token has not expired, and confirming that any time-based claims (such as nbf or iat) are valid. If everything checks out, decoded contains the payload that was originally signed, and the request is allowed to proceed.

Handling Missing, Expired, or Invalid Tokens

Middleware must distinguish between missing credentials and invalid credentials because they represent different conditions. When the header is absent or incorrectly formatted, the client has not provided any authentication information. A 401 response communicates that authentication is required before accessing the route.

If a token is present but fails verification, there are several potential causes. The most common ones include:

  • Expired tokens: Tokens with an exp timestamp in the past should be rejected immediately.
  • Tampered signatures: If the signature does not match the expected value, the token may have been modified or generated with an incorrect key.
  • Mismatched algorithms: Verification will fail if the signing algorithm does not match the expected header value.
  • Malformed payloads: Invalid Base64URL encoding or missing required fields can trigger verification errors.

In these cases, returning a 403 response indicates that the request was understood but will not be authorized. This separation between 401 and 403 helps clients understand whether they need to authenticate or whether the provided token is unacceptable.

Attaching User Information to req.user

Once verification succeeds, the middleware attaches the decoded payload to req.user. This convention is widely used in Express.js because it provides a predictable location for identity information throughout the request lifecycle. Any route handler or downstream middleware can inspect req.user to retrieve identifiers, roles, or other claims included in the token. This approach avoids repeated parsing, ensures consistency, and makes authorization checks more straightforward.

For example, an admin-only route might check:

if (req.user.role !== 'admin') {
  return res.status(403).json({ message: 'Access denied' });
}

Attaching the decoded data at the middleware stage ensures that this logic remains clean and focused.

By centralizing verification in a single middleware function, your application maintains a predictable and maintainable authentication flow. Protected routes remain concise, and all token-related checks occur in one place, making it easier to update signing keys, adjust error handling, or introduce new authorization rules later.

Access Tokens vs Refresh Tokens

A scalable authentication system often relies on two types of tokens: a short-lived access token and a long-lived refresh token. Each serves a different purpose, and together they create a predictable, secure flow for clients while allowing the server to limit the impact of stolen or misused credentials. Understanding how these tokens work and how they interact is essential before implementing a complete authentication strategy.

Why You Need Both

Access tokens are meant to be short-lived. Their primary job is to authorize the client when it calls protected API routes. Keeping the lifetime short limits the exposure if the token is intercepted or accidentally logged by a client or proxy. Because access tokens contain claims such as the user identifier, expiration time, and assigned roles, they allow servers to validate the request without querying a session database. This aligns well with stateless APIs and horizontally scaled environments where multiple backend services may need to authorize the same user.

Refresh tokens serve a different purpose. They allow the client to remain signed in without prompting for credentials every time the access token expires. Instead of forcing a full login, the client presents the refresh token to request a new access token from the server. Refresh tokens remain valid for longer periods and allow the system to maintain user sessions across browser tabs, mobile devices, and multiple application pages.

The combination creates a balance between security and usability. Short access token lifetimes reduce the impact of leaks, while refresh tokens preserve the user session. This approach avoids forcing the user to authenticate repeatedly and reduces the number of times passwords or OAuth credentials must be transmitted to the server.

Token Lifetimes

A common pattern is to use an access token lifetime of around fifteen minutes and a refresh token lifetime of several days. These values reflect a practical compromise between convenience and safety.

Token Type Typical Lifetime Purpose
Access token ~15 minutes Authorize API requests. Short lifetime reduces risk if leaked.
Refresh token ~7 days Allows the client to obtain new access tokens without reauthentication.

These values are not strict rules but represent a practical starting point for many systems. Short lifetimes support strong security controls, and longer refresh token lifetimes allow uninterrupted usage across sessions.

Short access token lifetimes help ensure that permissions remain current. For example, if a user’s role changes, the new access token generated at the next refresh will reflect this update. Long-lived refresh tokens, meanwhile, allow clients to create new access tokens without re-prompting for credentials.

Some systems adopt rolling refresh token lifetimes, where each refresh operation issues a new refresh token. Others use absolute expiration limits, preventing a refresh token from being valid indefinitely. These decisions depend on the level of control required and the sensitivity of the application.

Secure Storage of Refresh Tokens

Refresh tokens have a higher security requirement than access tokens because they can create new valid sessions. If they are leaked, an attacker can repeatedly obtain fresh access tokens. For browser-based applications, the safest storage mechanism is an HttpOnly cookie. An HttpOnly cookie cannot be accessed by JavaScript and is protected from common attacks such as cross-site scripting. Adding the Secure attribute ensures the cookie is only sent over HTTPS, and the SameSite attribute helps limit exposure to cross-site request contexts.

For native mobile or desktop applications, different storage mechanisms apply, such as secure storage APIs available within the operating system. In these environments, refresh tokens can be stored in encrypted containers or platform-provided credential stores.

Example Authentication Flow

A complete authentication workflow involving access and refresh tokens typically follows these steps:

  1. User logs in: The client submits their credentials. Once the server verifies them, it creates an access token and a refresh token.

    • The access token is returned in the response body or header.
    • The refresh token is sent as an HttpOnly cookie, preventing direct client-side access.
  2. Client uses the access token: The client attaches the access token to each request using the Authorization: Bearer <token> header. The server validates the signature and uses the claims to identify the user.

  3. Access token expires: When the access token reaches its expiration time, the server stops accepting it. The client then needs to request a new one.

  4. Client requests a new access token: Because the refresh token is stored in an HttpOnly cookie, it is included automatically when the client calls the refresh endpoint. The client does not need to manually attach it.

  5. Server validates the refresh token: The server checks whether the refresh token is valid, has not expired, and has not been revoked. If the token fails any of these checks, the client must log in again.

  6. Server rotates the refresh token: To reduce the risk of replay attacks, the server issues a brand-new refresh token each time the client performs a refresh. This means old tokens become invalid once new ones are issued.

  7. Client receives updated tokens: The server returns the new access token and updates the refresh cookie. The client continues to use the access token on subsequent requests.

This flow allows users to remain signed in for extended periods while keeping session management scalable and secure. The system only needs to verify short-lived tokens during most requests, and the refresh process gives the server a controlled point where it can revoke or limit access.

Token Refresh Flow Implementation

Once access and refresh tokens are part of the authentication design, the next step is implementing a secure and reliable refresh flow. The refresh endpoint is responsible for validating the refresh token, issuing a new access token, and rotating the refresh token when appropriate. This process helps maintain long-lived sessions while limiting the impact of compromised tokens.

The /auth/refresh Endpoint

A typical refresh flow begins with a dedicated endpoint such as /auth/refresh. The client calls this endpoint when the access token expires. If the application stores the refresh token in an HttpOnly cookie, the cookie accompanies the request automatically, and the server retrieves it from the incoming headers.

A basic structure for the endpoint looks like this:

import jwt from 'jsonwebtoken';
import cookieParser from 'cookie-parser';
app.use(cookieParser());

export async function refreshTokenHandler(req, res) {
  const token = req.cookies?.refreshToken;

  if (!token) {
    return res.status(401).json({ message: 'Refresh token missing' });
  }

  jwt.verify(token, process.env.REFRESH_SECRET, async (err, decoded) => {
    if (err) {
      return res.status(403).json({ message: 'Invalid or expired refresh token' });
    }

    // Additional checks (e.g., token version) happen here

    const newAccessToken = jwt.sign(
      { sub: decoded.sub, role: decoded.role },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );

    // A new refresh token will also be generated here if using rotation

    return res.json({ accessToken: newAccessToken });
  });
}

This example shows the basic structure, but a production system adds additional safeguards, such as token rotation and version tracking.

Verifying Refresh Tokens Before Issuing New Access Tokens

A refresh token should undergo stricter validation than an access token, since it grants the ability to continue a session. Beyond signature validation, the server must check that the token is still active, has not expired, and is recognized by the system.

Common verification steps include:

  • Checking the signature using the refresh token secret or public key.
  • Validating standard claims such as sub, iat, and exp.
  • Confirming the token’s current status using stored metadata.

If any step fails, the server should deny the request and require the user to authenticate again. This helps control the token lifecycle and reduces unwanted access.

Preventing Replay Attacks with Token Versioning or a Revocation List

Refresh tokens are long-lived, which makes them valuable targets for attackers. To reduce the risk of replay attacks, the server should not rely solely on the token’s signature. Instead, it should track the validity of each refresh token through one of two patterns:

Token Versioning (Database-Backed)

In this design, each user has a token version stored in the database. The refresh token also includes this version in its payload. When the user attempts a refresh:

  1. The server verifies the refresh token signature.
  2. The server compares the token’s version with the stored version.
  3. If the versions match, the user receives new tokens.
  4. If they differ, the token is rejected.

Rotating the refresh token involves incrementing the stored version and issuing a new token with the updated value. Any stolen tokens that use the older version become invalid immediately.

Revocation List

A revocation list stores identifiers of refresh tokens that should no longer be trusted. The refresh token contains a unique identifier (such as a UUID), which the server checks against the list. If the ID is present, the request is denied. When a user logs out or a token is suspected of compromise, the ID is added to the list.

This approach works well when each refresh token has its own identifier and lifecycle.

Bringing the Flow Together

A complete refresh flow integrates all these steps:

  1. Client calls /auth/refresh with the HttpOnly refresh cookie.
  2. Server verifies the refresh token’s signature and expiration.
  3. Server validates the token’s version or checks the revocation list.
  4. Server issues a new access token.
  5. Server rotates the refresh token by issuing a new one and updating stored metadata.
  6. Client receives the updated tokens and continues without a new login.

This structure helps maintain long-lived sessions while ensuring that compromised tokens cannot be used repeatedly. It also provides the server with full control over session lifecycles, making it suitable for real-world systems that require predictable authentication behavior.

Protecting Routes and Role-Based Access Control (RBAC)

Once tokens are verified, the next step is controlling what authenticated users are allowed to do. Authentication confirms who the user is; authorization determines what the user can access. Express.js applications commonly implement authorization through middleware that inspects the decoded token payload and ensures that the user has the required role or permission before they reach a protected route. This creates a clear, predictable structure for enforcing access rules across the API.

Protecting Routes with Middleware

Route protection begins with the authentication middleware defined earlier. By placing authentication logic ahead of a route handler, you ensure that only requests with valid access tokens reach the endpoint. Any route that requires a logged-in user should include this middleware.

An example of a protected route might look like this:

import { authenticateToken } from '../middleware/authenticate.js';

app.get('/api/profile', authenticateToken, (req, res) => {
  res.json({ message: `Hello, ${req.user.sub}` });
});

In this pattern, the route handler runs only if the token was verified successfully. The req.user object, created during verification, provides the user’s identifier and any claims required for authorization.

Enforcing Roles and Permissions from the Token Payload

Many APIs distinguish between different types of users. Some endpoints may require administrative privileges, while others may require editor or subscriber access. Instead of storing roles in a session, a common approach is to embed the user’s role in the access token at sign-in. Since the payload is controlled by the server, these claims provide a consistent source of information for enforcing permissions.

To implement authorization checks, you can create middleware that examines req.user.role (or any other field) and compares it against the required permission for that route. This method keeps authorization logic organized and reusable.

Example: adminOnly Middleware

Here’s a simple example of an adminOnly middleware that restricts access to administrative routes:

export function adminOnly(req, res, next) {
  if (!req.user || req.user.role !== 'admin') {
    return res.status(403).json({ message: 'Access denied' });
  }
  next();
}

This middleware assumes the access token contains a role claim, such as:

{
  "sub": "12345",
  "role": "admin",
  "exp": 1735689600
}

To use this middleware, apply it after the authentication middleware:

app.delete('/api/admin/users/:id', authenticateToken, adminOnly, (req, res) => {
  // Administrative logic here
  res.json({ message: 'User deleted' });
});

The flow for this protected route is straightforward:

  1. authenticateToken ensures the user is logged in.
  2. adminOnly ensures the user has the correct role.
  3. The route handler executes only when both checks succeed.

This layered approach keeps authorization predictable and easy to maintain. Additional roles or more complex permission checks can be introduced by creating new middleware or expanding the logic to read multiple claims from req.user.

Common Pitfalls and Security Practices

Working with JWTs requires careful handling across the entire authentication pipeline. Although tokens simplify authorization for distributed systems, design choices made during storage, signing, and validation can introduce risks if not implemented correctly. The following practices address common mistakes and outline how to build a secure JWT workflow suitable for production environments.

Avoid Storing JWTs in localStorage

A frequent mistake is storing access or refresh tokens in localStorage. Since localStorage is accessible to any JavaScript running on the page, it becomes an easy target for cross-site scripting attacks. If an attacker injects script into the application, they can immediately extract the token and use it to impersonate the user.

For browser-based clients, refresh tokens should be stored in HttpOnly cookies. HttpOnly cookies are not exposed to client-side scripts, significantly reducing the chance of token theft through script injection. These cookies should also be configured with:

  • Secure: Ensures the cookie is only sent over HTTPS.
  • SameSite=lax or strict: Limits cross-site transmission.
  • Short domain scope: Prevents other subdomains from accessing the cookie.

Access tokens, which expire quickly, can stay in memory during the session. They should not be stored in localStorage or sessionStorage.

Do Not Place Sensitive Data in JWT Payloads

Because JWT payloads are simply encoded, not encrypted, their contents can be decoded by anyone who obtains the token. This means tokens should never contain sensitive data such as passwords, email addresses, billing data, or internal identifiers that must remain private.

A good practice is to limit the payload to essential authorization information:

  • User identifier (sub)
  • User role or permission level
  • Token version or session state identifier
  • Expiration (exp)

Anything confidential should stay on the server and be retrieved through secured API calls after authentication.

Always Set exp and Enforce Expiration Checks

Tokens without an expiration time are a major security risk. A leaked token in this scenario remains valid indefinitely. Including an exp claim ensures that the token becomes invalid after a predictable period. For access tokens, short expiration windows (such as fifteen minutes) limit exposure and support frequent updates to user permissions.

Verification must also enforce expiration. It is not enough to include the exp claim; your server must reject any token whose expiration has passed. Many libraries handle this automatically, but it is important to validate that expiration checks are enabled and not bypassed during development.

Use Strong Secrets or Asymmetric Signing Algorithms

If using HS256 or other symmetric algorithms, the signing secret must be long, random, and protected within environment variables. Weak or guessable secrets are vulnerable to brute-force attacks, allowing attackers to forge valid tokens.

Applications that require higher assurance or operate in distributed environments often choose asymmetric algorithms such as RS256 or ES256. These algorithms separate the signing and verification keys, allowing the server to keep the private key secure while distributing the public key to verification services. This separation reduces key exposure and simplifies validation in microservice ecosystems.

Handle Token Revocation with a Server-Side Strategy

Since JWTs are stateless, revoking a token before its expiration requires a server-side mechanism. Without such a mechanism, a stolen refresh token remains valid until it expires. There are two common approaches that address this:

Token Versioning

Each user’s record in the database includes a version number. The refresh token embeds this version as part of its payload. When the user refreshes their access token:

  1. The server verifies the refresh token’s signature.
  2. The server checks whether the version embedded in the token matches the version stored in the database.
  3. If they match, the server issues new tokens and increments the stored version.

Any refresh token with an outdated version becomes invalid automatically. This approach is simple, predictable, and works well for most applications.

Revocation Lists

Instead of versioning, some systems assign a unique identifier to every refresh token and record revoked tokens in a server-side list. During a refresh request, the server checks whether the token’s ID appears in the list. If it does, the request is denied.

This method works well for scenarios involving multiple active refresh tokens per user or per device.

Pro Tip: Use JWKS Endpoints for Public Key Rotation

Applications using asymmetric signing often publish their public keys through a JWKS (JSON Web Key Set) endpoint. A JWKS endpoint exposes the public keys in a structured format that clients can retrieve automatically. This setup provides several advantages:

  • Seamless key rotation: New keys are added to the JWKS endpoint while old keys remain available for ongoing token validation.
  • Distributed verification: Multiple services can validate tokens without sharing private keys.
  • Reduced configuration overhead: Clients retrieve updated keys without redeployment.

This pattern is widely used in systems that integrate with identity providers such as Auth0, or Okta, and it is equally useful in custom authentication systems with microservices.

Advanced JWT Security

As authentication systems evolve, long-term security depends on adopting practices that remain reliable under changing standards, new attack vectors, and distributed application architectures. While the fundamentals of signing and verifying JWTs remain the same, modern systems benefit from asymmetric signing, predictable key rotation, and selective use of encryption. Newer specifications such as OAuth 2.1, OpenID Connect, and PASETO also complement JWT-based designs or, in some cases, replace them. In this section, we highlight security approaches that align with current best practices and prepare systems for future requirements.

Asymmetric Signing with RS256 and ES256

Many real-world systems now prefer asymmetric signing algorithms such as RS256 (RSA) or ES256 (Elliptic Curve). These algorithms use a private key to sign tokens and a public key to verify them. The separation of keys has several advantages:

  • The private key remains on the authorization server, reducing exposure.
  • Multiple backend services can validate tokens without sharing sensitive material.
  • Compromising a public key does not allow an attacker to forge tokens.

RS256 is widely supported and remains a practical choice for systems that require compatibility. ES256, based on modern elliptic-curve cryptography, offers smaller key sizes and faster verification while meeting current security standards. As more services adopt distributed authentication patterns, asymmetric signing becomes a natural fit.

Key Rotation with JWKS

Key rotation is central to maintaining long-term security, especially in distributed or multi-service environments. A JSON Web Key Set (JWKS) endpoint provides a structured way to expose public keys that downstream services can fetch and use during verification. Because the JWKS document can publish multiple keys at once, rotation becomes straightforward:

  1. The server generates a new key pair.
  2. The public portion is added to the JWKS endpoint.
  3. New tokens are signed with the new private key.
  4. Old keys remain available until all older tokens expire.

This approach avoids coordination issues that arise when services rely on static key configurations. Instead, verification logic retrieves updated keys periodically, ensuring that rotated keys propagate naturally through the system. This pattern is widely used by modern identity providers and has become a common expectation in production deployments.

Optional Encryption with JWE

Although JWT payloads are encoded, they are not encrypted. Applications that need to protect sensitive claims can use JSON Web Encryption (JWE). A JWE wraps the signed token (or its payload) in an encrypted structure that only the intended recipient can decrypt. This allows tokens to carry confidential data without exposing it to intermediaries or client-side environments.

JWE is optional and should be applied selectively, since it introduces additional processing overhead. Many APIs only need integrity, not confidentiality, and therefore rely solely on JSON Web Signature (the signing standard used by JWTs). However, for systems that store sensitive attributes in tokens or operate in highly regulated environments, JWE provides an additional layer of protection.

As authentication ecosystems mature, several related standards have become common complements or alternatives to JWT-based systems.

  • OAuth 2.1 simplifies earlier OAuth versions by clarifying security requirements and removing deprecated patterns. Although access tokens in OAuth 2.1 can be JWTs, many implementations use opaque tokens validated through introspection endpoints.
  • OpenID Connect (OIDC) layers authentication on top of OAuth 2.0/2.1 and standardizes how identity claims are delivered. OIDC Id Tokens are often JWTs, and the specification integrates naturally with JWKS publishing and key rotation.
  • PASETO offers a more opinionated token format that removes insecure algorithm choices and enforces strict cryptographic defaults. Its design aims to reduce common implementation mistakes seen in poorly configured JWT systems.

These standards do not replace JWTs entirely but influence how organizations choose to structure authentication and identity management. Many systems still rely on JWTs for access tokens while using OAuth 2.1 or OIDC for authorization and identity, or evaluating PASETO for specific use cases.

Best Practices Checklist

A well-designed JWT authentication system depends on consistent handling of tokens throughout their lifecycle. The following checklist summarizes the key practices that help maintain security, performance, and predictable behavior in production environments.

Token Storage and Transport

  • Store refresh tokens in HttpOnly, Secure cookies to reduce exposure to script-based attacks.
  • Keep access tokens in memory during the active session and avoid storing them in localStorage or sessionStorage.
  • Use HTTPS for all token-related communication.

Token Structure and Claims

  • Include required claims such as sub, iat, and exp.
  • Set short expiration times for access tokens (for example, fifteen minutes).
  • Limit payload contents to fields that support authorization; do not include sensitive data.
  • Use a token version or session identifier when implementing rotation.

Signing and Verification

  • Use strong secrets for HS256 or rely on asymmetric algorithms such as RS256 or ES256.
  • Store signing keys in environment variables rather than source code.
  • Verify signatures, timestamps, and expected algorithms on every request.
  • Reject tokens with missing or expired exp claims.

Refresh Token Security

  • Validate refresh tokens with stricter controls than access tokens.
  • Rotate refresh tokens on every refresh, creating a new token and invalidating the previous one.
  • Maintain a revocation strategy using token versioning or a server-side revocation list.

Middleware and Authorization Logic

  • Centralize token verification in dedicated authentication middleware.
  • Populate req.user with decoded claims for downstream handlers.
  • Implement role-based or permission-based checks using separate authorization middleware.

Key and Infrastructure Management

  • Rotate signing keys regularly to limit long-term exposure.
  • Use JWKS endpoints to publish public keys for distributed verification.
  • Refresh JWKS caches periodically across services to support smooth key transitions.

Future-Oriented Considerations

  • Evaluate whether OAuth 2.1 or OpenID Connect is appropriate for your system’s scale.
  • Consider PASETO for environments that require strict cryptographic defaults.
  • Use JWE only when confidentiality is required; otherwise rely on signed JWTs to reduce overhead.

FAQs

1. What is JWT authentication in Express.js?

JWT (JSON Web Token) authentication is a method to verify a user’s identity using a compact, self-contained digital token.

In an Express.js application, this process typically follows these steps:

  1. A user logs in with their credentials (e.g., username and password).
  2. The Express server validates these credentials.
  3. Upon success, the server generates a signed JWT. This token contains a “payload” of data, such as the user’s ID.
  4. The server sends this JWT back to the client.
  5. The client then stores this token and includes it (usually in the Authorization header) with every future request to protected routes.
  6. An Express middleware on the server intercepts each request, verifies the token’s signature, and (if valid) identifies the user, granting them access.

This approach is stateless, meaning the server does not need to store session information in a database to identify the user.

2. How do I create a JWT token in Node.js?

You create a JWT in Node.js using a library, most commonly jsonwebtoken. The process involves calling the sign() method.

This method takes three arguments:

  1. Payload: An object containing the data you want to store in the token. This data is publicly readable, so do not include sensitive information like passwords. A common payload is { userId: 12345 }.
  2. Secret Key: A private, complex string known only to your server (e.g., process.env.JWT_SECRET). This key is used to create the token’s signature.
  3. Options: An object to configure the token’s behavior, such as its expiration time.

For example, to create a token that expires in one hour:

jwt.sign({ userId: user.id }, 'your-very-strong-secret-key', { expiresIn: '1h' })

3. How do I verify a JWT token in Express middleware?

You verify a JWT by creating a middleware function that runs before your protected route handlers. This function uses the verify() method from the jsonwebtoken library.

Here is the general flow:

  1. Extract the token: Get the token from the Authorization header. It is usually in the format Bearer <token>.
  2. Verify the token: Use the jwt.verify() method, passing in the token and the same secret key you used to sign it.
  3. Handle the result:
    • If successful, verify() returns the decoded payload. You can attach this payload to the Express request object (e.g., req.user = decodedPayload;) and call next() to proceed to the route handler.
    • If verification fails (e.g., the signature is invalid or the token is expired), the method will throw an error. You should catch this error and send an error response, such as a 401 (Unauthorized) or 403 (Forbidden) status.

4. What’s the difference between session and token-based auth?

The primary difference is that session-based authentication is stateful, while token-based authentication is stateless.

  • Stateful (Sessions): The server creates a unique session ID for a user, stores session data (like the user ID) in a database or cache, and sends only the session ID to the client (usually in a cookie). On every request, the server must look up that session ID in its database to identify the user.
  • Stateless (Tokens): The server bundles the user’s identifying information inside the token’s payload, signs it to prevent tampering, and sends the entire token to the client. The server does not store the token. When the client sends the token back, the server just verifies the signature using its secret key to trust the data inside it.

This table compares the two approaches:

Feature Session-Based (Stateful) Token-Based (Stateless)
Server Storage Requires server-side storage (e.g., database, Redis) for each active session. No server storage needed for the token itself; it’s self-contained.
Scalability More complex to scale across multiple servers, as it requires a shared session store. Scales easily, as any server with the secret key can verify the token.
Data Held by Client Only a reference (Session ID). The token itself, which contains the payload (e.g., user ID).
Primary Risk Susceptible to CSRF (Cross-Site Request Forgery) if cookie-based. Susceptible to XSS (Cross-Site Scripting) if stored in localStorage.

5. How secure is JWT authentication?

JWT authentication is secure when implemented correctly. Many common vulnerabilities come from misunderstanding its core principles.

Key security considerations:

  • JWTs are Signed, Not Encrypted: The payload of a JWT is just Base64 encoded, not encrypted. Anyone can decode it. You must never store sensitive information (like passwords or credit card numbers) in the payload. The signature only ensures that the data has not been tampered with.
  • HTTPS is Required: Always transmit tokens over an HTTPS connection to prevent man-in-the-middle attacks where an attacker could steal the token.
  • Use a Strong Secret Key: Your secret key must be long, complex, and unpredictable. Store it securely in an environment variable (process.env.JWT_SECRET), not hard-coded in your application.
  • Use Short Expiration Times: An access token should have a short lifespan (e.g., 15-60 minutes). This limits the amount of time an attacker can use a stolen token. For longer sessions, use refresh tokens.
  • Do Not Use alg: 'none': Always specify a strong algorithm (like HS256 or RS256). Some libraries once allowed an alg of none, which the server would accept without checking any signature.

6. How can I implement refresh tokens in Express.js?

Refresh tokens allow a user to maintain a long-lived session without the risk of a long-lived access token. The access token is short-lived, while the refresh token is long-lived.

This is the standard flow:

  1. On Login: The server generates two tokens:
    • An access token with a short expiration (e.g., 15 minutes).
    • A refresh token with a long expiration (e.g., 7 days).
  2. Storage: The server sends both tokens to the client. The server must also store the refresh token (or a reference to it) in a secure database, linking it to the user.
  3. Making Requests: The client sends only the access token for normal requests to protected routes.
  4. Access Token Expiration: When the access token expires, the server responds with a 401 error.
  5. Getting a New Token: The client application detects this 401 error. It then sends the refresh token to a special, dedicated endpoint (e.g., /api/token/refresh).
  6. Refresh Verification: The Express server receives the refresh token, checks its database to see if it’s a valid, active token for that user, and ensures it has not been revoked.
  7. Issuing New Tokens: If the refresh token is valid, the server generates a new access token (and optionally a new refresh token) and sends it back to the client. The client can then retry the original request that failed. If the refresh token is invalid, the user must log in again.

7. Should I store JWTs in cookies or localStorage?

Both localStorage and cookies are common storage options, but they present different security trade-offs. For web applications, the most recommended approach is using HttpOnly cookies.

This table outlines the pros and cons of each:

Storage Pros Cons
localStorage Easy to use. JavaScript can easily read and write to it. Vulnerable to XSS (Cross-Site Scripting). If an attacker injects a script onto your page, they can read the token from localStorage and impersonate the user.
Works well for non-browser clients (e.g., mobile apps). Token must be manually added to every request header using JavaScript.
Cookies Can be set as HttpOnly, which prevents JavaScript from accessing them. This provides strong protection against XSS attacks. Vulnerable to CSRF (Cross-Site Request Forgery). An attacker can trick a user’s browser into sending a request to your site, and the cookie will be attached automatically.
Can be sent automatically with every request to your domain. This CSRF risk can be mitigated by setting the SameSite=Strict or SameSite=Lax attribute on the cookie.

Recommendation: The most secure method for a web browser is to store the JWT in a cookie with the following attributes:

  • HttpOnly: Prevents XSS.
  • Secure: Ensures the cookie is only sent over HTTPS.
  • SameSite=Strict (or Lax): Provides strong protection against CSRF.

Conclusion

Implementing JWT authentication in Express.js requires more than generating and verifying tokens. A secure and reliable system depends on clear token lifecycles, predictable middleware behavior, and careful handling of signing keys and storage mechanisms. By combining short-lived access tokens with long-lived refresh tokens, enforcing role-based access, and applying strong security practices such as key rotation and safe storage, you can build an authentication workflow that scales across services and remains resilient over time.

The approaches covered in this article provide a foundation for building production-grade systems while leaving room for future improvements such as asymmetric signing, JWKS endpoints, and integration with OAuth 2.1 or OpenID Connect. With these patterns in place, your Express.js applications can support modern authentication needs while maintaining clarity, safety, and long-term maintainability.

To learn more about authentication in Node.js, check out the following tutorials:

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about our products

About the author(s)

Danny Denenberg
Danny Denenberg
Author
Manikandan Kurup
Manikandan Kurup
Editor
Senior Technical Content Engineer I
See author profile

With over 6 years of experience in tech publishing, Mani has edited and published more than 75 books covering a wide range of data science topics. Known for his strong attention to detail and technical knowledge, Mani specializes in creating clear, concise, and easy-to-understand content tailored for developers.

Category:
Tags:

Still looking for an answer?

Was this helpful?


This textbox defaults to using Markdown to format your answer.

You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!

/api/creteNewUser

I believe you meant createNewUser? :)

Thanks for this post!

Cheers for the tutorial Danny!

Sadly Mario’s post doesn’t detail the drawbacks complexities and risks of using JWTs for securing a REST backend. They have a place, but your article should point out they’re not a one-size-fits-all solution, especially for a backends built with node.js and deployed as a monolith. Revocation and refresh is non-trivial, for instance.

If folks are going to use them in place of sessions, please stop recommending Local Storage to persisting them client-side. For browser-based clients, the node app should send & retrieve JWTs via a HTTPS-only secure cookie, with either samesite as strict for known browsers or a separate csrf-token stored locally and validated against the payload. Check this vid for a good overview of the correct approach.

Really helpful. Was initially going with a wrong token format
// const token = authorization.replace("Bearer ", " "); This article really helped. Thanks!

Thank you. This port helped me a lot.

How to validate a token in multi nodes environment? For examples there are 3 servers. User logged on server 1 and token is generated there. Now api call is sent to server 1 with token. This request will be validated and data will be returned.

If load balancer redirects this api call to server 2 then? Server 2 does not recognize that token.

How to store token in some shared location and validate from there?

how is the value of req.headers['authorization'] set?

DO NOT STORE THE JWT IN LOCALSTORAGE

If you store it inside localStorage, it’s accessible by any script inside your page (which is as bad as it sounds, as an XSS attack can let an external attacker get access to the token).

Don’t store it in local storage (or session storage). If any of the third-party scripts you include in your page gets compromised, it can access all your users’ tokens.

The JWT needs to be stored inside an httpOnly cookie, a special kind of cookie that’s only sent in HTTP requests to the server, and it’s never accessible (both for reading or writing) from JavaScript running in the browser.

I’d like to stress on what alessandroamella said, DO NOT STORE the JWT token in Local Storage.

Very simple and helpful thanks.

Couple of amendments if I may (some already mentioned by others):

/api/creteNewUser => /api/createNewUser

process.env.ACCESS_TOKEN_SECRET as string should be process.env.TOKEN_SECRET as string

thank you, your explanation is very easy to understand

Creative CommonsThis work is licensed under a Creative Commons Attribution-NonCommercial- ShareAlike 4.0 International License.
Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

The developer cloud

Scale up as you grow — whether you're running one virtual machine or ten thousand.

Get started for free

Sign up and get $200 in credit for your first 60 days with DigitalOcean.*

*This promotional offer applies to new accounts only.