Programming / Node.js
Tutorial

Build a REST API in Node.js with Express and JWT Auth

Learn how to build a practical REST API in Node.js with Express and JWT authentication, including setup, protected routes, validation, testing, and production readiness checks.

Build a REST API in Node.js with Express and JWT Auth

What you will build

A REST API becomes operationally useful only when it has a clear request model, predictable error handling, and a way to protect sensitive endpoints. In this tutorial, you will build a small Node.js API with Express and JWT authentication that includes a health check, a login endpoint, and one protected route. By the end, you will know how to issue a token, send it back on requests, verify it in middleware, and confirm that the API behaves correctly before production use.

The finished state is intentionally modest: a maintainable API skeleton that you can extend with real user storage, authorization rules, rate limiting, and hardened token handling. If you only need public endpoints, JWT may be unnecessary; if you need authenticated access without session state on the server, this pattern is a practical fit. For deeper token-design considerations, see How to Secure Node.js APIs with JWT Authentication and Secure Node.js API Authentication with JWT and OAuth 2.0.

Prerequisites and stop-here checks

Goal

Confirm that the runtime, package manager, and security assumptions are valid before you write code. JWT-based APIs are straightforward to build, but they are easy to misuse if secrets are weak, token expiry is ignored, or you plan to store sensitive data in the token itself.

Action

You should have:

  • Node.js 18 or later installed
  • npm available
  • A terminal and a code editor
  • A local development environment where you can run environment variables safely
  • A clear decision on how user credentials will be verified, even if the first version uses a simple in-memory check

Stop here if you do not yet know where the signing secret will come from in each environment. Do not hardcode the JWT secret in source control. Also stop here if you plan to use this pattern for high-risk authorization without defining claim validation, expiry, and key rotation expectations.

Expected output

A development setup where you can create a project, install dependencies, and run the API locally without exposing secrets.

Validation

Run:

node -v
npm -v

Confirm that both commands succeed and that your Node.js version is current enough for your project policies.

Common failure

  • Using an outdated runtime that breaks package installation or modern syntax
  • Storing secrets directly in code or committing them to version control
  • Treating a demo login flow as production authentication without later replacement

Prepare the project

Goal

Create a minimal Express application structure with the dependencies needed for routing, JSON parsing, and token signing.

Action

Create a new project directory and initialize it:

mkdir node-express-jwt-api
cd node-express-jwt-api
npm init -y
npm install express jsonwebtoken dotenv
npm install -D nodemon

Then create a simple structure:

node-express-jwt-api/
  src/
    server.js
    auth.js
    middleware/
      authenticateToken.js
  .env
  package.json

Add scripts to package.json:

{
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js"
  }
}

Create a .env file for development only:

PORT=3000
JWT_SECRET=replace_with_a_long_random_secret
JWT_EXPIRES_IN=15m

Expected output

A runnable Node.js project with Express, JWT support, environment-based configuration, and a development reload workflow.

Validation

Check that dependencies installed successfully and that npm run dev can start the app once server.js exists.

Common failure

  • Forgetting to add dotenv, which causes process.env values to be undefined
  • Using a short or guessable secret
  • Running a development watcher in production by accident

Build the Express server

Goal

Create the API server, enable JSON request parsing, and add a basic endpoint that proves the service is alive.

Action

Create src/server.js:

require('dotenv').config();

const express = require('express');
const authRoutes = require('./auth');

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

app.use(express.json());

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

app.use('/auth', authRoutes);

app.listen(port, () => {
  console.log(`API listening on port ${port}`);
});

Expected output

The server starts and exposes GET /health, returning a small JSON response.

Validation

Start the API and test it:

npm run dev
curl http://localhost:3000/health

Expected response:

{"status":"ok"}

Common failure

  • Missing express.json(), which breaks JSON body parsing on POST requests
  • Incorrect route mounting, which causes /auth paths not to resolve
  • Port conflicts if another process is already listening on the chosen port

Implement token issuance

Goal

Create a login endpoint that validates credentials and returns a signed JWT when authentication succeeds.

Action

Create src/auth.js:

const express = require('express');
const jwt = require('jsonwebtoken');

const router = express.Router();

const demoUser = {
  id: 1,
  username: 'admin',
  password: 'change-me'
};

router.post('/login', (req, res) => {
  const { username, password } = req.body;

  if (username !== demoUser.username || password !== demoUser.password) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  const payload = {
    sub: demoUser.id,
    username: demoUser.username
  };

  const token = jwt.sign(payload, process.env.JWT_SECRET, {
    expiresIn: process.env.JWT_EXPIRES_IN || '15m'
  });

  return res.status(200).json({
    accessToken: token
  });
});

module.exports = router;

This example uses a fixed demo user to keep the implementation focused. In a real system, credential checks should query your identity source, compare hashed passwords, and enforce lockout or throttling rules where appropriate.

Expected output

A /auth/login endpoint that returns a JWT when the supplied credentials match the expected user.

Validation

Send a login request:

curl -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"change-me"}'

Expected response includes an accessToken field with a long encoded string.

Common failure

  • Missing request body JSON
  • Secret not loaded from .env
  • Using a static demo password beyond local development

Protect routes with JWT middleware

Goal

Verify the token on protected requests and reject unauthenticated calls early and consistently.

Action

Create src/middleware/authenticateToken.js:

const jwt = require('jsonwebtoken');

function authenticateToken(req, res, next) {
  const authHeader = req.headers.authorization;
  const token = authHeader && authHeader.startsWith('Bearer ')
    ? authHeader.slice(7)
    : null;

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

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    return next();
  } catch (error) {
    return res.status(401).json({ message: 'Invalid or expired token' });
  }
}

module.exports = authenticateToken;

Update src/server.js with one protected endpoint:

const authenticateToken = require('./middleware/authenticateToken');

app.get('/profile', authenticateToken, (req, res) => {
  res.status(200).json({
    message: 'Protected content',
    user: req.user
  });
});

Expected output

Requests to /profile fail without a valid token and succeed when the client sends Authorization: Bearer <token>.

Validation

Without a token:

curl http://localhost:3000/profile

Expected response:

{"message":"Missing bearer token"}

With a token copied from /auth/login:

curl http://localhost:3000/profile \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"

Expected response includes message and the decoded user claim.

Common failure

  • Accepting tokens from the wrong header format
  • Verifying tokens with a different secret than the one used to sign them
  • Returning detailed verification errors that expose too much implementation detail

Decide what belongs in the token

Goal

Keep JWT payloads small and safe to expose to clients, because the token is only signed, not encrypted.

Action

Limit the payload to claims needed for request processing, such as subject, username, and token expiry. Avoid putting passwords, secrets, or unnecessary personal data into the token. If downstream services need roles or tenant identifiers, add only the minimal claims required and document how they are validated.

Expected output

A token that is sufficient for authorization decisions without carrying sensitive or bloated data.

Validation

Inspect the encoded payload after decoding the token and confirm that it contains only intended claims.

Common failure

  • Treating JWTs as a storage mechanism for sensitive data
  • Allowing claim sprawl without a validation policy
  • Depending on unverified client-side claims for server-side authorization

Validate the API end to end

Goal

Confirm the request flow works from login through protected access and that failure cases behave as expected.

Action

Test these cases in order:

  1. Login with valid credentials and confirm token issuance.
  2. Call /profile with no token and confirm a 401 response.
  3. Call /profile with an expired or invalid token and confirm a 401 response.
  4. Call /profile with a valid token and confirm a 200 response.

For a quick manual check, you can use curl or a REST client. If you prefer an automated check, write a small integration test that starts the app, gets a token, and asserts the protected route response.

Expected output

A reproducible validation path showing that authentication is enforced and that failures are safe.

Validation

A healthy implementation should demonstrate:

  • 200 OK for successful login
  • 401 Unauthorized for missing or invalid bearer tokens
  • A small, predictable response body for the protected endpoint

Common failure

  • A protected route accidentally left public
  • Token expiry not being enforced because verification is skipped
  • Accepting malformed authorization headers

Operational follow-up before production use

Goal

Turn the tutorial implementation into something safer to operate in a real environment.

Action

Review these controls before production:

  • Replace the demo user with real identity lookup and password hashing
  • Store JWT_SECRET in a secrets manager or environment injection mechanism, not in source
  • Set an expiry appropriate to your risk model and session requirements
  • Decide whether you need refresh tokens, revocation, or key rotation
  • Add request logging and rate limiting to reduce brute-force and abuse risk; if that is part of your deployment plan, Node.js Rate Limiting with Redis: Secure API Throttling explains a practical pattern
  • Ensure every protected route uses the middleware consistently
  • Verify that error messages do not disclose token internals or credential hints

Expected output

A clear production checklist for the API, with the remaining gaps understood before deployment.

Validation

Before release, confirm:

  • Secrets are injected securely
  • Token expiry is set and tested
  • Unauthorized requests return 401
  • Logs do not expose tokens
  • Authentication and authorization responsibilities are separated in code

Common failure

  • Deploying with demo credentials still enabled
  • Reusing one secret across environments without rotation plans
  • Assuming authentication alone is enough when authorization rules are still undefined

Final takeaway

You now have a working Express API with a JWT login flow, middleware-based route protection, and a validation path you can repeat locally. The implementation is intentionally small, but the operational discipline matters: verify the token format, protect every sensitive route, keep claims minimal, and confirm that secrets and expiry settings are correct before production use. If you extend this pattern carefully, the same structure can support a more complete API without turning authentication into an afterthought.

Use this guidance together with git branching checklist and branch and bound algorithm to connect the workflow with related operational context already available on the site.

Continue learning

Related content