New to Rust? Grab our free Rust for Beginners eBook Get it free →
Create a JWT Authentication API in Node.js

A JSON Web Token (JWT) API issues a short-lived token after your application has authenticated an account, then verifies it before a protected route runs. I ran JWT_SECRET=development-only-secret node test.mjs on Node v24.18.0 with express 5.2.1 and jsonwebtoken 9.0.3, and the saved terminal receipt returned 201 for token issuance, 200 for an accepted bearer token, and 401 when the header was absent.
What this API proves
The login route accepts a userId only so the token flow stays visible, and an application must replace that demonstration input with a user lookup and password-hash verification before signing a token.
The token carries a subject claim, expiry, issuer, and audience, which the protected route verifies with the HS256 algorithm selected by the API.
Create the Node.js project
Create a directory, initialize npm, and install Express with jsonwebtoken. Use a long random value for JWT_SECRET in your deployment environment instead of committing a secret to the project.
npm init -y
npm install express jsonwebtoken
Issue a short-lived token
Save the following file as app.mjs. The login handler returns 400 when userId is missing, then creates a token whose sub claim identifies the authenticated account.
import express from "express";
import jwt from "jsonwebtoken";
const app = express();
app.use(express.json());
const issuer = "example-auth-api";
const audience = "example-client";
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET must be set before starting the API");
}
function createAccessToken(userId) {
return jwt.sign({ sub: userId }, secret, {
algorithm: "HS256",
audience,
expiresIn: "15m",
issuer,
});
}
function requireAccessToken(req, res, next) {
const authorization = req.get("authorization");
const token = authorization?.startsWith("Bearer ")
? authorization.slice(7)
: undefined;
if (!token) {
return res.status(401).json({ error: "Bearer token required" });
}
try {
req.auth = jwt.verify(token, secret, {
algorithms: ["HS256"],
audience,
issuer,
});
next();
} catch {
res.status(401).json({ error: "Invalid or expired token" });
}
}
app.post("/login", (req, res) => {
const { userId } = req.body;
if (typeof userId !== "string" || userId.length === 0) {
return res.status(400).json({ error: "userId is required" });
}
// Replace this demonstration input with a verified user record and password check.
res.status(201).json({ accessToken: createAccessToken(userId) });
});
app.get("/profile", requireAccessToken, (req, res) => {
res.json({ message: "Protected response", userId: req.auth.sub });
});
export default app;
Verify the bearer token
The requireAccessToken middleware reads the Authorization header, removes the Bearer prefix, and passes the token to jwt.verify. Verification checks the signature, expiry, issuer, audience, and permitted algorithm before the profile handler can read req.auth.sub.
A JSON Web Token is a signed claim set, not an encrypted password container. Avoid placing passwords, access keys, or private profile data in its payload because anyone holding the token can decode its contents.
Run the API test
The test starts the Express app on an ephemeral local port and exercises the issue, accepted-token, and missing-token paths. Save it as test.mjs beside app.mjs, then run it with JWT_SECRET set for the process.
import app from "./app.mjs";
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve));
const { port } = server.address();
const baseUrl = `http://127.0.0.1:${port}`;
try {
const login = await fetch(`${baseUrl}/login`, {
body: JSON.stringify({ userId: "ada" }),
headers: { "content-type": "application/json" },
method: "POST",
});
const { accessToken } = await login.json();
const profile = await fetch(`${baseUrl}/profile`, {
headers: { authorization: `Bearer ${accessToken}` },
});
const missingToken = await fetch(`${baseUrl}/profile`);
console.log("POST /login", login.status);
console.log("GET /profile with token", profile.status, await profile.json());
console.log("GET /profile without token", missingToken.status, await missingToken.json());
} finally {
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
Run the test command from the project directory.
JWT_SECRET=development-only-secret node test.mjs

JWT boundaries to keep in your API
Use a short expiration and verify issuer, audience, and algorithms every time you accept a token. The jsonwebtoken project documents these options, and RFC 7519 defines the registered claims used by JWTs.
- Keep the signing secret in your deployment secret store or environment, never in source control.
- Authenticate the account before you call jwt.sign. Token signing does not validate a password or prove an account exists.
- Use HTTPS when a client sends an Authorization header, and return 401 for missing, expired, or invalid tokens.
Where OAuth fits
JWT verification protects a route after your application has issued or accepted a token. If you need delegated access through a provider such as Google, follow the OAuth 2.0 flow before deciding where your API validates the resulting identity.
Add the authentication boundary
Put your user lookup and password verification immediately before createAccessToken, then keep the three-route test in your API suite. That boundary prevents the sample login endpoint from becoming an accidental authentication system.
For token claim details, read RFC 7519 and the jsonwebtoken documentation alongside your application requirements.




