npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

express-oauth2-dpop

v1.0.9

Published

Protect your Express API (resource server) routes with OAuth 2.0 JWT Bearer and DPoP-bound access tokens issued by an authorization server.

Readme

express-oauth2-dpop

Middleware for Express to protect your API routes (resource server) with OAuth 2.0 JWT Bearer and DPoP-bound access tokens issued by an authorization server.

Release Downloads License

Key Features

  • Supports both Bearer and DPoP access tokens: Seamlessly validates standard Bearer tokens and DPoP-bound JWT access tokens for enhanced security.
  • 🔐 Built-in route protection: Automatically protect all routes or use fine-grained control with protectRoute() middleware.
  • 🎯 Scope-based authorization: Enforce required OAuth scopes on a per-route basis.
  • 🧠 Pluggable JTI store: Prevent DPoP replay attacks by plugging in your own JTI store (eg. Redis).
  • ⚙️ Flexible configuration: Customize issuer, audience, JWKS URI, DPoP enforcement, and more.
  • 🌐 Standards-compliant: Follows RFC 9449 (DPoP) and RFC 6750 (OAuth 2.0 Authorization Framework).
  • 🧪 Minimal setup for testing: Includes a simple in-memory JTI store for local development and testing environments.

Table of Contents

Installation

npm install express-oauth2-dpop

Getting started

Set up the auth middleware

To enable token validation in your Express app, apply the authMiddleware globally. This middleware will extract and validate access tokens (Bearer or DPoP) on incoming requests.

import express from "express";
import { authMiddleware, protectRoute } from "express-oauth2-dpop";
import { InMemoryJtiStore } from "./store/in-memory-jti-store.js";

const app = express();

app.use(
  authMiddleware({
    issuer: "https://auth.example.com",
    audience: "https://api.example.com",
    jwksUri: "https://auth.example.com/.well-known/openid-configuration/jwks", // Optional: only needed if your JWKS endpoint differs from the default {{issuer}}/.well-known/jwks.json
    protectRoutes: false, // Optional: defaults to true. Use false if you want to manually protect routes with the protectRoute() middleware.
    enforceDPoP: true, // Optional: only needed if you want to enforce DPoP-bound access token
    nonceSecret: process.env.NONCE_SECRET!, // eg. 954860b66dc9d7fc4a8a0f1ceccb285b8d912b144584ffbc253ce336ee40685b
    jtiStore: new InMemoryJtiStore(), // For testing only. Use Redis or a similar store in production.
  })
);

Protect routes

If you prefer more control protecting routes, set protectRoutes: false and use the protectRoute() middleware:

import express from "express";
import { authMiddleware, protectRoute } from "express-oauth2-dpop";

app.use(authMiddleware({
  // ...
  protectRoutes: false,
}));

app.get(
  "/public",
  (req, res) => {
    res.json({
      message: "This is a public endpoint",
    });
  }
);

app.get(
  "/protected",
  protectRoute(),
  (req, res) => {
    res.json({
      message: "This is a protected endpoint",
    });
  }
);

Enforcing DPoP

You can enforce the use of a DPoP-bound access token on specific routes using the enforceDPoP option:

import express from "express";
import { authMiddleware, protectRoute } from "express-oauth2-dpop";

app.use(authMiddleware({
  // ...
  protectRoutes: false,
}))

app.get(
  "/protected/dpop",
  protectRoute({
    enforceDPoP: true
  }),
  (req, res) => {
    res.json({
      message: "This is a protected endpont with DPoP-bound access token",
    });
  },
);

Require scopes

To restrict access based on scopes, use the scope option. The request will be rejected if the token doesn't include at least all required scopes:

app.get(
  "/protected/scope",
  protectRoute({
    scope: ["read:profile", "write:profile"],
  }),
  (req, res) => {
    res.json({
      message: "This is a scope-protected endpoint",
    });
  },
);

DPoP JTI Store

To prevent DPoP token replay attacks, the middleware requires a JTI Store, a mechanism to store and validate unique JWT IDs (jti claims).

For testing purposes, you can use in-memory store. For production usage, you should implement your own store (e.g., Redis).

Implementing the custom store

To create a custom store, extend the AbstractJtiStore class and implement two methods:

  • get(identifier: string): Promise<JtiData | undefined> — Retrieves a JTI entry (if it exists).
  • set(identifier: string, data: JtiData): Promise<void> — Stores a JTI entry with its expiration.

Redis example

import { AbstractJtiStore } from "express-oauth2-dpop";
import { createClient } from "redis";
import type { JtiData } from "express-oauth2-dpop";

const client = createClient({
  // ...
});

await client.connect();

export class RedisJtiStore extends AbstractJtiStore {
  async set(identifier: string, data: JtiData): Promise<void> {
    client.set(identifier, JSON.stringify(data), {
      expiration: {
        type: "EXAT",
        value: data.expiresAt,
      },
    });
  }

  async get(identifier: string): Promise<JtiData | undefined> {
    const data = await client.get(identifier);

    if (!data) {
      return undefined;
    }

    return JSON.parse(data) as JtiData;
  }
}

In-Memory example

export class InMemoryJtiStore extends AbstractJtiStore {
  private store: Map<string, JtiData> = new Map();

  /**
   * In-memory implementation of JTI store, intended for development/testing use only.
   *
   * A cleanup routine runs every 60 seconds to remove expired JTI entries
   * and prevent unbounded memory growth.
   */
  constructor() {
    super();
    setInterval(() => {
      void this.deleteExpired();
    }, 60 * 1000);
  }

  async set(identifier: string, data: JtiData): Promise<void> {
    this.store.set(identifier, data);
  }

  async get(identifier: string): Promise<JtiData | undefined> {
    return this.store.get(identifier);
  }

  async delete(identifier: string): Promise<void> {
    this.store.delete(identifier);
  }

  async deleteExpired(): Promise<void> {
    const now = Math.floor(Date.now() / 1000);

    for (const [identifier, data] of this.store.entries()) {
      if (data.expiresAt <= now) {
        this.store.delete(identifier);
      }
    }
  }
}

Using the custom store

Pass your store instance to the middleware configuration:

import express from "express";
import { authMiddleware, protectRoute } from "express-oauth2-dpop";
import { RedisJtiStore } from "./store/redis-jti-store.js";

const app = express();

app.use(
  authMiddleware({
    // ...
    jtiStore: new RedisJtiStore(),
  })
);

DPoP Nonce

To comply with RFC 9449 §9 and mitigate token replay attacks, we support issuing and validating DPoP nonces in a stateless manner.

Instead of maintaining nonce state on the server, express-oauth2-dpop uses AES-GCM encryption to embed the nonce's data (including the ath hash) directly in the encrypted payload. The nonce is:

  • Encrypted using a symmetric key derived from the nonceSecret (defined in authMiddleware options).
  • Self-contained and verifiable without needing server-side storage.
  • Short-lived (default expiration: 5 minutes)

[!WARNING] The nonceSecret must be a securely generated, high-entropy string (e.g., using openssl rand -hex 32) and must be kept private. Changing this secret will invalidate all existing nonces. However, clients will receive a use_dpop_nonce error along with a new DPoP-Nonce value, allowing them to retry. In practice, this means it is safe to rotate the nonceSecret as long as the client supports retry mechanisms.

Behaviour of DPoP Nonce errors

When a client sends a valid DPoP-bound token but omits the required nonce (or sends an invalid one), the middleware will:

  • Respond with 401 Unauthorized
  • Include a JSON response body with error and error_description:
{
  "error": "use_dpop_nonce",
  "error_description": "DPoP 'nonce' claim is required"
}
  • Include a DPoP-Nonce response header with a newly issued nonce:
DPoP-Nonce: eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..t6hDs2CjR5E1ZQHF.wvhSMdO3oVzLIaxjRpVhA-bI7c5qEpUzsq8c46d55g_HypEWnjznDx1TY3ObzvUXS0vWAvuiuX5caDcUXWuedLU64jKaiidtpvhbOZj6_K4XecZmFImw.RI9tutYfmHUCbfnL-mAYnA
  • Include a WWW-Authenticate response header:
WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="DPoP 'nonce' claim is required"

The client must extract the DPoP-Nonce value from headers and include it in the next DPoP proof under the nonce claim.

[!NOTE] When the current DPoP nonce is close to expiration, a new nonce may also be included in successful responses (e.g., 2xx status), to allow proactive refresh.