Skip to main content

Protecting Routes with Cognito JWT Middleware

Once a user logs in, they receive a Cognito Access Token — a JWT signed by your User Pool. To protect Express routes, you need middleware that verifies this token on every request.

How JWT Verification Works

Cognito signs tokens using RSA private keys. It publishes the corresponding public keys (JWKS) at a well-known URL:

https://cognito-idp.<region>.amazonaws.com/<UserPoolId>/.well-known/jwks.json

The aws-jwt-verify library:

  1. Fetches and caches those public keys on first use
  2. On every request, verifies the token's signature and claims locally — no network call to Cognito
  3. Throws if the token is expired, tampered with, or issued by a different User Pool

This is the correct approach. Never call Cognito's API to validate a token on every request — it's slow and has rate limits.

Install

npm install aws-jwt-verify

Create the Verifier

config/jwtVerifier.js
import { CognitoJwtVerifier } from "aws-jwt-verify";

const verifier = CognitoJwtVerifier.create({
userPoolId: process.env.COGNITO_USER_POOL_ID,
tokenUse: "access", // Verify Access Tokens (use "id" for ID Tokens)
clientId: process.env.COGNITO_CLIENT_ID,
});

export default verifier;

Auth Middleware

middlewares/authenticate.js
import verifier from "../config/jwtVerifier.js";

export async function authenticate(req, res, next) {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ message: "Authorization header missing or malformed" });
}

const token = authHeader.split(" ")[1];

try {
const payload = await verifier.verify(token);
req.user = payload; // Attach decoded claims to the request
next();
} catch (err) {
return res.status(401).json({ message: "Invalid or expired token" });
}
}

The verified payload contains claims like:

{
"sub": "user-uuid-from-cognito",
"username": "rizwan@example.com",
"email": "rizwan@example.com",
"cognito:groups": ["admin"],
"exp": 1712345678,
"iat": 1712342078
}

Role-Based Authorization Middleware

Cognito lets you add users to Groups (e.g., admin, moderator). Use this to implement role-based access control:

middlewares/authorize.js
export function authorize(...allowedRoles) {
return (req, res, next) => {
const userGroups = req.user?.["cognito:groups"] || [];
const hasRole = allowedRoles.some((role) => userGroups.includes(role));

if (!hasRole) {
return res.status(403).json({ message: "You do not have permission to access this resource" });
}

next();
};
}

Applying the Middleware to Routes

routes/products.js
import express from "express";
import { authenticate } from "../middlewares/authenticate.js";
import { authorize } from "../middlewares/authorize.js";

const router = express.Router();

// Public — no auth needed
router.get("/", async (req, res) => {
res.json({ products: [] });
});

// Protected — any authenticated user
router.get("/my-orders", authenticate, async (req, res) => {
// req.user is available here
const userId = req.user.sub;
res.json({ userId, orders: [] });
});

// Admin only
router.delete("/:id", authenticate, authorize("admin"), async (req, res) => {
res.json({ message: `Product ${req.params.id} deleted` });
});

// Multiple allowed roles
router.patch("/:id", authenticate, authorize("admin", "moderator"), async (req, res) => {
res.json({ message: `Product ${req.params.id} updated` });
});

export default router;

Apply Auth Globally (Optional)

If most of your routes are protected, apply authenticate globally and mark public routes explicitly:

index.js
import express from "express";
import { authenticate } from "./middlewares/authenticate.js";
import authRouter from "./routes/auth.js";
import productRouter from "./routes/products.js";

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

// Auth routes are public — register them before the global middleware
app.use("/auth", authRouter);

// All routes below this line require a valid Cognito token
app.use(authenticate);

app.use("/products", productRouter);

app.listen(3000);

Accessing User Info in Route Handlers

After authenticate runs, req.user holds all the token's claims:

router.get("/profile", authenticate, async (req, res) => {
res.json({
id: req.user.sub, // Cognito user UUID
email: req.user.email,
username: req.user.username,
groups: req.user["cognito:groups"] || [],
});
});

Testing Protected Routes with Postman

  1. First call POST /auth/login to get an accessToken
  2. In the protected request, go to Authorization → Bearer Token
  3. Paste the accessToken
  4. Send the request — the middleware verifies it automatically

Key Takeaways

  • Verify Cognito JWTs locally using cached public keys — never call Cognito's API per request
  • aws-jwt-verify handles key caching, signature verification, and claim validation automatically
  • Always attach the verified payload to req.user so downstream handlers can read user context
  • Use Cognito Groups + authorize() middleware for role-based access control
  • Register public routes (like /auth) before any global authenticate middleware