Skip to main content

Protect Your Express.js API with JWT Authentication

This guide demonstrates how to add JWT authentication to your Express.js API using the express-oauth2-jwt-bearer SDK. You’ll validate access tokens issued by Auth0, protect API routes, and implement scope-based authorization.
Prerequisites: Before you begin, ensure you have:
  • Node.js 18 LTS or newer (supports ^18.12.0 || ^20.2.0 || ^22.1.0 || ^24.0.0)
  • npm 8+ (or yarn/pnpm)
  • An Auth0 account (free tier available)
  • Basic familiarity with Express.js

Get Started

1. Create a new Express project

Create a new directory and initialize a Node.js project:
mkdir my-secure-api && cd my-secure-api
npm init -y

2. Install dependencies

Install Express, the authentication SDK, and dotenv for environment variable management:
npm install express express-oauth2-jwt-bearer dotenv
For TypeScript projects, also install type definitions:
npm install -D typescript @types/express @types/node

3. Configure your Auth0 API

You need an Auth0 API to issue access tokens for your Express application. Configure Auth0 using the CLI or manually via the Dashboard:
If you have the Auth0 CLI installed, run:
auth0 apis create \
  --name "My Express API" \
  --identifier "https://api.example.com" \
  --signing-alg "RS256"
After creation, note the Identifier value—this is your AUTH0_AUDIENCE.

4. Create environment configuration

Create a .env file in your project root with your Auth0 configuration:
# .env

# Your Auth0 tenant domain (without https:// prefix shown in Dashboard)
AUTH0_AUDIENCE=https://api.example.com
AUTH0_DOMAIN=your-tenant.us.auth0.com

# Server configuration
PORT=3000
ℹ️ Finding your Auth0 Domain: In the Auth0 Dashboard, your domain appears in the top-left corner or under SettingsGeneral. It typically looks like dev-abc123.us.auth0.com.
VariableDescriptionExample
AUTH0_DOMAINYour Auth0 tenant domaindev-abc123.us.auth0.com
AUTH0_AUDIENCEThe API Identifier you createdhttps://api.example.com

5. Create the server

Create server.js (or server.ts for TypeScript) with the following code:
// server.js
require('dotenv').config();
const express = require('express');
const { auth } = require('express-oauth2-jwt-bearer');

const app = express();
const port = process.env.PORT || 3000;

// Configure JWT validation middleware
const checkJwt = auth({
  issuerBaseURL: `https://${process.env.AUTH0_DOMAIN}`,
  audience: process.env.AUTH0_AUDIENCE,
});

// Public route - no authentication required
app.get('/api/public', (req, res) => {
  res.json({
    message: 'Hello from a public endpoint! No authentication required.',
  });
});

// Protected route - requires valid JWT
app.get('/api/private', checkJwt, (req, res) => {
  res.json({
    message: 'Hello from a private endpoint!',
    user: req.auth.payload.sub,
  });
});

// Error handling middleware
app.use((err, req, res, next) => {
  console.error(err.stack);
  
  const status = err.status || 500;
  const message = err.message || 'Internal Server Error';
  
  res.status(status).json({
    error: err.code || 'server_error',
    message: status === 401 ? 'Authentication required' : message,
  });
});

app.listen(port, () => {
  console.log(`API server running on http://localhost:${port}`);
});

6. Run your API

Start the server:
node server.js
For TypeScript:
npx ts-node server.ts
ℹ️ Your API is now running at http://localhost:3000.
Checkpoint: Verify your API is protecting routes correctly: Test the public endpoint (should return 200):
curl http://localhost:3000/api/public
Test the private endpoint without a token (should return 401):
curl http://localhost:3000/api/private
You should receive:
  • Public endpoint: {"message":"Hello from a public endpoint!..."}
  • Private endpoint: {"error":"unauthorized","message":"Authentication required"}

7. Test with a valid token

To test the protected endpoint with a valid access token:
  1. Go to Auth0 DashboardApplicationsAPIs
  2. Select your API → Test tab
  3. Copy the generated access token
Test your protected endpoint:
curl http://localhost:3000/api/private \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
You should receive a response like:
{
  "message": "Hello from a private endpoint!",
  "user": "auth0|abc123..."
}
Success! Your Express API is now protected with JWT authentication.

Add Scope-Based Authorization

Scopes allow fine-grained access control. You can require specific scopes for different endpoints.

1. Configure scopes in Auth0

  1. In the Auth0 Dashboard, go to ApplicationsAPIs → Your API
  2. Navigate to the Permissions tab
  3. Add the following scopes:
    • read:messages - Read messages
    • write:messages - Write messages
    • admin:access - Administrative access

2. Protect routes with scopes

Update your server to use scope-based authorization:
const { auth, requiredScopes } = require('express-oauth2-jwt-bearer');

// ... existing setup ...

// Requires 'read:messages' scope
app.get('/api/messages', checkJwt, requiredScopes('read:messages'), (req, res) => {
  res.json({
    messages: [
      { id: 1, text: 'Hello!' },
      { id: 2, text: 'World!' },
    ],
  });
});

// Requires 'admin:access' scope
app.get('/api/admin', checkJwt, requiredScopes('admin:access'), (req, res) => {
  res.json({
    message: 'Admin access granted',
    userId: req.auth.payload.sub,
  });
});

3. Request tokens with scopes

When obtaining tokens, include the required scopes in the authorization request. The scopes granted will be included in the token’s scope claim.
⚠️ Important: If a request lacks the required scope, the API returns 403 Forbidden with an insufficient_scope error.

Add Claim Validation

Beyond scopes, you can validate custom claims in the JWT payload.

Validate specific claim values

const { auth, claimEquals, claimIncludes, claimCheck } = require('express-oauth2-jwt-bearer');

// Require exact claim value
app.get('/api/org/:orgId', 
  checkJwt, 
  claimEquals('org_id', 'org_123'),
  (req, res) => {
    res.json({ message: 'Organization access granted' });
  }
);

// Require claim to include all specified values
app.get('/api/roles', 
  checkJwt, 
  claimIncludes('roles', 'editor', 'viewer'),
  (req, res) => {
    res.json({ message: 'Role check passed' });
  }
);

// Custom claim validation logic
app.get('/api/premium', 
  checkJwt, 
  claimCheck((claims) => {
    return claims.subscription === 'premium' && claims.verified === true;
  }),
  (req, res) => {
    res.json({ message: 'Premium feature access granted' });
  }
);

Troubleshooting


Advanced Usage


Complete Example

Here’s a complete Express API with all features:
// server.js
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { 
  auth, 
  requiredScopes, 
  claimCheck,
  UnauthorizedError,
  InsufficientScopeError,
} = require('express-oauth2-jwt-bearer');

const app = express();
const port = process.env.PORT || 3000;

// Middleware
app.use(cors({
  origin: process.env.ALLOWED_ORIGIN || '*',
  exposedHeaders: ['WWW-Authenticate'],
}));
app.use(express.json());

// JWT validation middleware
const checkJwt = auth({
  issuerBaseURL: `https://${process.env.AUTH0_DOMAIN}`,
  audience: process.env.AUTH0_AUDIENCE,
});

// Public endpoints
app.get('/api/public', (req, res) => {
  res.json({ message: 'Public endpoint - no authentication required' });
});

app.get('/health', (req, res) => {
  res.json({ status: 'healthy' });
});

// Protected endpoints
app.get('/api/private', checkJwt, (req, res) => {
  res.json({
    message: 'Private endpoint',
    user: req.auth.payload.sub,
  });
});

// Scope-protected endpoint
app.get('/api/messages', checkJwt, requiredScopes('read:messages'), (req, res) => {
  res.json({
    messages: [
      { id: 1, text: 'Hello from the API!' },
    ],
  });
});

// Admin endpoint with scope and claim check
app.get('/api/admin', 
  checkJwt, 
  requiredScopes('admin:access'),
  claimCheck((claims) => claims.role === 'admin'),
  (req, res) => {
    res.json({
      message: 'Admin access granted',
      userId: req.auth.payload.sub,
    });
  }
);

// Error handling
app.use((err, req, res, next) => {
  console.error('Error:', err.message);
  
  if (err instanceof InsufficientScopeError) {
    return res.status(403).json({
      error: 'insufficient_scope',
      message: 'Missing required permissions',
    });
  }
  
  if (err instanceof UnauthorizedError) {
    return res.status(err.status).set(err.headers).json({
      error: err.code || 'unauthorized',
      message: 'Authentication required',
    });
  }
  
  res.status(500).json({
    error: 'server_error',
    message: 'An unexpected error occurred',
  });
});

app.listen(port, () => {
  console.log(`🚀 API server running at http://localhost:${port}`);
  console.log(`   - Public:  http://localhost:${port}/api/public`);
  console.log(`   - Private: http://localhost:${port}/api/private`);
});

Next Steps

Now that your Express API is protected with JWT authentication, you can:
  • Add more protected routes with different scope requirements
  • Implement refresh token rotation for long-lived sessions
  • Add rate limiting to protect against abuse
  • Connect a frontend application that obtains tokens from Auth0
  • Enable DPoP for enhanced token security (proof-of-possession)

Useful Resources


Congratulations! You’ve successfully protected your Express.js API with JWT authentication using Auth0 and express-oauth2-jwt-bearer.