Skip to main content

Command Palette

Search for a command to run...

Authentication and JWT: How to Secure Your Express Application

Updated
30 min read
Authentication and JWT: How to Secure Your Express Application

Introduction: The Problem of Knowing Who You Are Talking To

Imagine you built a web application. Users can create posts, save preferences, access their private data, and perform actions that matter to them. The application works perfectly.

Then you realize something uncomfortable. Right now, anyone can access anything. There is no way to distinguish between users. There is no way to know if the person requesting to delete a post is the person who created it. There is no way to restrict certain pages to logged-in users only. Anyone who knows the URL can reach any endpoint and do anything.

This is the problem that authentication solves.

Authentication is the process of verifying that someone is who they claim to be. When a user logs in with an email address and password, authentication is the process of checking those credentials and confirming that yes, this person is who they say they are. From that point forward, the server needs a reliable way to remember that confirmation, so that every subsequent request from that user does not require logging in again.

How the server remembers that confirmation — how it maintains the knowledge of who a user is across multiple requests — is where different approaches diverge, and it is where JSON Web Tokens come in.

This article will take you through the complete picture: why authentication is needed, what JWT is, how it is structured, how the login flow works, how tokens travel with requests, and how to use them to protect your Express routes. We will build a working implementation step by step.


Part 1: Two Ways to Remember Who Someone Is

Before diving into JWT specifically, it is worth understanding the two main approaches to maintaining authentication state across requests, because JWT is specifically a response to the limitations of the older approach.

The Traditional Approach: Sessions

In session-based authentication, after a user logs in successfully, the server creates a session. A session is a record stored on the server that says something like: "User with ID 42, who is Alice Johnson, logged in at 10:30am and their session is valid until 10:30pm."

The server gives this session a unique identifier — a long random string called a session ID. That session ID is sent to the browser, which stores it in a cookie. On every subsequent request, the browser automatically sends the cookie back to the server. The server reads the session ID from the cookie, looks up that ID in its session storage, finds the corresponding user information, and knows who is making the request.

LOGIN:
User sends credentials → Server verifies → Server creates session →
Server sends session ID in cookie → Browser stores cookie

SUBSEQUENT REQUEST:
Browser sends cookie with session ID → Server looks up session ID →
Server finds user information → Request proceeds

This works. It has worked for decades. But it has a characteristic that creates problems in certain architectures: the server must store session data somewhere. Every server in your infrastructure needs access to that session storage. If you have multiple servers behind a load balancer, and a user's request goes to a different server each time, every server needs to be able to find the session.

This requirement means sessions create state on the server. The server is stateful — it remembers things between requests. For small applications with a single server, this is fine. For larger applications distributed across many servers, it requires a shared session store (like Redis) that all servers can access, which adds infrastructure complexity.

The Modern Approach: Tokens

Token-based authentication approaches this problem differently. After a user logs in successfully, the server creates a token — a self-contained package of information that proves the user's identity. This token is sent to the client. The client stores it and sends it back with every subsequent request.

The crucial difference: the server does not store anything. All the information needed to verify the user is contained within the token itself. When the server receives a request with a token, it can verify the token and extract the user information from it without looking anything up in a database or storage system.

The server is stateless. It does not remember anything between requests. Every request is evaluated on its own merits, using only the information contained in the token.

LOGIN:
User sends credentials → Server verifies → Server creates token
with user info embedded → Server sends token to client → Client stores token

SUBSEQUENT REQUEST:
Client sends token with request → Server verifies token is genuine →
Server reads user info from token → Request proceeds
No lookup required. No shared storage required.

This stateless approach scales naturally. Any server can verify any token without needing access to shared session storage. This is why token-based authentication became popular as applications grew more distributed.

JSON Web Tokens are the most widely used implementation of this token-based approach.


Part 2: What JWT Is

JWT stands for JSON Web Token. It is an open standard (documented in RFC 7519) that defines a compact, self-contained way to transmit information securely between parties as a JSON object.

Let us unpack that definition piece by piece.

Compact means the token is small enough to be sent efficiently in HTTP headers, URL parameters, or cookies. Tokens need to travel with every request, so their size matters.

Self-contained means the token carries all the information needed to identify the user. The server does not need to look up anything externally to understand who is making a request. Everything is in the token.

Secure transmission means the information in the token can be verified as genuine. A JWT is cryptographically signed, which means the server can confirm that the token was created by a trusted party (itself) and has not been tampered with since it was created.

That last point is critical and worth emphasizing. A JWT is not encrypted by default — the information inside it can be read by anyone who has the token. But it is signed, which means it cannot be altered without detection. If someone tried to change the user ID in a token to pretend to be a different user, the signature verification would fail and the server would reject the token.

This distinction between readable and tamper-proof is important. Do not put sensitive information like passwords or credit card numbers in a JWT. Put identifiers and non-sensitive claims. The security guarantee is that the token is genuine and unmodified, not that its contents are hidden.


Part 3: The Structure of a JWT

A JWT is a string that looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQyLCJlbWFpbCI6ImFsaWNlQGV4YW1wbGUuY29tIiwiaWF0IjoxNzAzMDAxMjM0LCJleHAiOjE3MDMwMDQ4MzR9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

It looks like random characters, but it has a very specific structure. Look carefully and you can see two dots in that string. Those dots divide the JWT into three distinct parts:

HEADER.PAYLOAD.SIGNATURE

Each part is separately Base64URL encoded — a process that takes data and represents it as a string of characters that are safe to use in URLs and HTTP headers. Base64URL encoding is not encryption. It is just a way of representing data as a string. You can decode it and read the contents.

Let us look at each part.

Part 1: The Header

The header is the first segment of the JWT. It is a JSON object that describes the token itself — what type of token it is and what algorithm was used to sign it.

Before encoding, it looks like this:

{
    "alg": "HS256",
    "typ": "JWT"
}

alg specifies the signing algorithm. HS256 stands for HMAC-SHA256, which is the most common choice for JWT signing. It uses a secret key to generate and verify the signature.

typ simply declares that this is a JWT.

After Base64URL encoding, this JSON becomes the first segment of the token:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

The header is metadata about the token. As a developer using JWT, you rarely need to think about the header — libraries handle it automatically.

Part 2: The Payload

The payload is the second segment. This is where the actual information about the user lives. The payload is also a JSON object, and each piece of information in it is called a claim.

A claim is simply a statement about something — in this case, statements about the user or about the token itself.

There are three types of claims:

Registered claims are predefined claim names that the JWT standard recommends or requires for specific purposes:

iss    Issuer — who created the token
sub    Subject — who the token is about (usually a user ID)
aud    Audience — who the token is intended for
exp    Expiration time — when the token expires (as a Unix timestamp)
iat    Issued at — when the token was created (as a Unix timestamp)
nbf    Not before — the token should not be accepted before this time
jti    JWT ID — a unique identifier for the token

Public claims are custom claims defined by the people using JWT. You can put whatever information is useful to your application:

{
    "userId": 42,
    "email": "alice@example.com",
    "role": "admin"
}

Private claims are custom claims agreed upon between the parties using them, not intended to be shared publicly.

A typical payload for a web application looks like this:

{
    "userId": 42,
    "email": "alice@example.com",
    "role": "user",
    "iat": 1703001234,
    "exp": 1703004834
}

iat is the timestamp when the token was created. exp is when it expires. The difference here is 3600 seconds, meaning this token is valid for one hour.

After Base64URL encoding, this JSON becomes the second segment of the token.

Remember: this payload is readable by anyone who has the token. It is not secret. Only put information that is appropriate to be visible.

Part 3: The Signature

The signature is the third and most important segment from a security standpoint. It is what makes JWT trustworthy.

The signature is created by taking the encoded header, adding a dot, adding the encoded payload, and then running that combined string through the signing algorithm using a secret key:

signature = HMAC-SHA256(
    encodedHeader + "." + encodedPayload,
    secretKey
)

The result is then Base64URL encoded and becomes the third segment.

When the server later receives a JWT, it verifies the signature by performing the same calculation using the same secret key. If the result matches the signature in the token, the token is genuine and unmodified. If even a single character in the header or payload was changed after the token was created, the signature will not match and the token is rejected.

The secret key is kept on the server and never shared. Only the server can create valid signatures, and only the server can verify them. This is what prevents users from creating their own tokens or modifying existing ones.

TOKEN CREATION (at login):
header + payload → HMAC-SHA256 with secret → signature
header.payload.signature = complete JWT

TOKEN VERIFICATION (on each request):
Take header and payload from received token
Run same HMAC-SHA256 with same secret
If result matches signature in token → token is genuine
If result does not match → token was tampered with → reject

Visualizing the Complete Structure

JWT = Base64URL(header) . Base64URL(payload) . Base64URL(signature)

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
    |
    Decoded: {"alg":"HS256","typ":"JWT"}

.

eyJ1c2VySWQiOjQyLCJlbWFpbCI6ImFsaWNlQGV4YW1wbGUuY29tIiwiaWF0IjoxNzAzMDAxMjM0LCJleHAiOjE3MDMwMDQ4MzR9
    |
    Decoded: {"userId":42,"email":"alice@example.com","iat":1703001234,"exp":1703004834}

.

SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
    |
    The signature — cannot be decoded into readable content
    It is verified by re-running the algorithm, not by decoding

Part 4: The Complete Login Flow

Now that we understand what a JWT is and how it is structured, let us trace the complete flow of authentication in a JWT-based system from the first login to subsequent authenticated requests.

Step 1: The User Submits Credentials

The user enters their email address and password on the login page. The browser sends these credentials to the server as an HTTP POST request:

POST /auth/login
Content-Type: application/json

{
    "email": "alice@example.com",
    "password": "her_password"
}

Step 2: The Server Verifies the Credentials

The server receives the credentials and checks them against the database. It finds the user record with that email address. It then verifies the password.

Passwords should never be stored in plain text in a database. They should be stored as hashes — the output of a one-way hashing function like bcrypt. When verifying a password, the server runs the submitted password through the same hashing function and compares the result to the stored hash. If they match, the password is correct.

Step 3: The Server Creates and Signs the JWT

If the credentials are valid, the server creates a JWT. It puts relevant user information in the payload and signs the token with its secret key:

const token = jwt.sign(
    {
        userId: user.id,
        email: user.email,
        role: user.role
    },
    process.env.JWT_SECRET,
    { expiresIn: '1h' }
);

Step 4: The Server Sends the Token to the Client

The server sends the token back in the response:

{
    "success": true,
    "message": "Login successful",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Step 5: The Client Stores the Token

The client (a browser, a mobile app, or any HTTP client) receives the token and stores it somewhere. For web applications, common storage locations are:

localStorage, which is simple to use but accessible to JavaScript running on the page, making it vulnerable to cross-site scripting attacks.

Cookies with the httpOnly flag, which prevents JavaScript from accessing the cookie. This is generally the more secure option for web applications.

For this article, we will use localStorage in examples for simplicity, but the httpOnly cookie approach is worth learning as you advance.

Step 6: The Client Sends the Token With Every Request

From this point on, every request the client makes to protected endpoints includes the token. The standard way to send a JWT is in the Authorization header using the Bearer scheme:

GET /api/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Step 7: The Server Verifies the Token on Each Request

When the server receives a request to a protected endpoint, it reads the token from the Authorization header, verifies the signature, checks that the token has not expired, and extracts the user information from the payload. If everything is valid, the request proceeds. If anything is wrong, the server rejects the request.

Client Request with token
        |
        v
Server reads Authorization header
        |
        v
Server extracts the token
        |
        v
Server verifies signature using secret key
        |
        v
Signature valid?
        |
    NO  → Reject with 401 Unauthorized
        |
    YES → Check expiration
               |
           Expired? → Reject with 401 Unauthorized
               |
           Not expired → Extract user info from payload
               |
               v
           Request proceeds with user info available

This complete flow — login, receive token, send token, verify token — is JWT authentication.


Part 5: Building a JWT Authentication System

Let us build a complete working implementation. We will create user registration, login, and protected routes.

Setup and Dependencies

Install the required packages:

npm install express bcryptjs jsonwebtoken dotenv

These packages do the following:

express is our web framework.

bcryptjs is a JavaScript implementation of the bcrypt password hashing function. We use it to hash passwords before storing them and to compare submitted passwords against stored hashes.

jsonwebtoken is the library for creating and verifying JWTs.

dotenv loads environment variables from a .env file into process.env, allowing us to keep secrets out of our code.

Create a .env file in your project root:

JWT_SECRET=your_very_long_random_secret_key_that_nobody_can_guess_easily
JWT_EXPIRES_IN=1h
PORT=3000

The JWT secret should be a long, random string. In production, generate it properly and keep it absolutely private. If someone gets your JWT secret, they can create valid tokens and impersonate any user in your system.

Creating the Server

Create server.js:

require('dotenv').config();

const express = require('express');
const app = express();

app.use(express.json());

// Routes
const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/user');

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

// 404 handler
app.use(function(req, res) {
    res.status(404).json({ error: 'Route not found' });
});

// Global error handler
app.use(function(err, req, res, next) {
    console.error('Unhandled error:', err.message);
    res.status(500).json({ error: 'Internal server error' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, function() {
    console.log('Server running on port ' + PORT);
});

Creating a Simple User Store

For this example, we will use an in-memory array instead of a real database. In a real application, you would use a database like MongoDB or PostgreSQL.

Create data/users.js:

// In a real application, this would be a database
const users = [];
let nextId = 1;

function findByEmail(email) {
    return users.find(function(user) {
        return user.email === email;
    });
}

function findById(id) {
    return users.find(function(user) {
        return user.id === id;
    });
}

function createUser(name, email, hashedPassword) {
    const newUser = {
        id: nextId,
        name: name,
        email: email,
        password: hashedPassword,
        createdAt: new Date().toISOString()
    };
    nextId = nextId + 1;
    users.push(newUser);
    return newUser;
}

module.exports = {
    findByEmail,
    findById,
    createUser
};

Creating the Authentication Middleware

The middleware that verifies JWTs is the most important piece. Create middleware/authenticate.js:

const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {

    // Step 1: Read the Authorization header
    const authHeader = req.headers['authorization'];

    // Step 2: Check that the header exists and has the right format
    if (!authHeader) {
        return res.status(401).json({
            error: 'Access denied. No token provided.',
            hint: 'Include an Authorization header with format: Bearer <token>'
        });
    }

    // The header should look like: "Bearer eyJhbGci..."
    // We need to split it and take the second part
    const parts = authHeader.split(' ');

    if (parts.length !== 2 || parts[0] !== 'Bearer') {
        return res.status(401).json({
            error: 'Invalid authorization format.',
            hint: 'Format should be: Bearer <token>'
        });
    }

    const token = parts[1];

    // Step 3: Verify the token
    try {
        const decoded = jwt.verify(token, process.env.JWT_SECRET);

        // Step 4: Attach the decoded user information to the request
        // This makes req.user available in all subsequent route handlers
        req.user = decoded;

        // Step 5: Pass control to the next middleware or route handler
        next();

    } catch (error) {

        // jwt.verify throws specific errors we can handle
        if (error.name === 'TokenExpiredError') {
            return res.status(401).json({
                error: 'Token has expired. Please log in again.'
            });
        }

        if (error.name === 'JsonWebTokenError') {
            return res.status(401).json({
                error: 'Invalid token. Please log in again.'
            });
        }

        // Unexpected error
        return res.status(500).json({
            error: 'Token verification failed.'
        });
    }
}

module.exports = authenticate;

Let us go through this middleware carefully because it is the heart of JWT protection.

req.headers['authorization'] reads the Authorization header from the incoming request. Header names are case-insensitive in HTTP, but Express normalizes them to lowercase, so we use the lowercase form.

We split the header value on a space to separate Bearer from the actual token string. If the format is not right, we reject immediately.

jwt.verify(token, process.env.JWT_SECRET) is the key call. This function:

  • Decodes the token

  • Recalculates the signature using the secret key

  • Compares the recalculated signature to the signature in the token

  • Checks that the token has not expired

  • Returns the decoded payload if everything is valid

  • Throws an error if anything is wrong

If verification succeeds, we attach the decoded payload to req.user. This means in any route handler that uses this middleware, req.user.userId, req.user.email, and req.user.role are all available.

We call next() to pass control to the next function in the middleware chain — the actual route handler.

Creating the Auth Routes

Create routes/auth.js:

const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const users = require('../data/users');

// ============================================================
// REGISTER: POST /auth/register
// ============================================================
router.post('/register', async function(req, res) {
    try {
        const { name, email, password } = req.body;

        // Validate required fields
        if (!name || !email || !password) {
            return res.status(400).json({
                error: 'Name, email, and password are all required'
            });
        }

        // Validate email format (basic check)
        if (!email.includes('@') || !email.includes('.')) {
            return res.status(400).json({
                error: 'Please provide a valid email address'
            });
        }

        // Validate password length
        if (password.length < 8) {
            return res.status(400).json({
                error: 'Password must be at least 8 characters long'
            });
        }

        // Check if email is already registered
        const existingUser = users.findByEmail(email);
        if (existingUser) {
            return res.status(409).json({
                error: 'An account with this email address already exists'
            });
        }

        // Hash the password before storing
        // The number 12 is the "salt rounds" — higher means more secure but slower
        // 12 is a good balance for most applications
        const hashedPassword = await bcrypt.hash(password, 12);

        // Create the user
        const newUser = users.createUser(name, email, hashedPassword);

        // Do not send back the password, even the hashed version
        res.status(201).json({
            success: true,
            message: 'Account created successfully',
            user: {
                id: newUser.id,
                name: newUser.name,
                email: newUser.email,
                createdAt: newUser.createdAt
            }
        });

    } catch (error) {
        console.error('Registration error:', error);
        res.status(500).json({ error: 'Registration failed. Please try again.' });
    }
});

// ============================================================
// LOGIN: POST /auth/login
// ============================================================
router.post('/login', async function(req, res) {
    try {
        const { email, password } = req.body;

        // Validate required fields
        if (!email || !password) {
            return res.status(400).json({
                error: 'Email and password are required'
            });
        }

        // Find the user by email
        const user = users.findByEmail(email);

        // IMPORTANT: Do not tell the user specifically whether
        // the email or the password was wrong. This prevents
        // attackers from discovering which email addresses
        // have accounts in your system.
        if (!user) {
            return res.status(401).json({
                error: 'Invalid email or password'
            });
        }

        // Compare the submitted password with the stored hash
        const passwordIsValid = await bcrypt.compare(password, user.password);

        if (!passwordIsValid) {
            return res.status(401).json({
                error: 'Invalid email or password'
            });
        }

        // Credentials are valid. Create the JWT.
        const tokenPayload = {
            userId: user.id,
            email: user.email,
            name: user.name
        };

        const token = jwt.sign(
            tokenPayload,
            process.env.JWT_SECRET,
            { expiresIn: process.env.JWT_EXPIRES_IN || '1h' }
        );

        // Calculate when the token expires for the client's reference
        const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();

        res.json({
            success: true,
            message: 'Login successful',
            token: token,
            expiresAt: expiresAt,
            user: {
                id: user.id,
                name: user.name,
                email: user.email
            }
        });

    } catch (error) {
        console.error('Login error:', error);
        res.status(500).json({ error: 'Login failed. Please try again.' });
    }
});

module.exports = router;

Two points in this login route deserve attention.

First, bcrypt.compare(password, user.password) does the password verification. It takes the plain-text password submitted by the user and the hashed password from the database, runs the bcrypt comparison, and returns true if they match. You never need to unhash a password — bcrypt comparison handles the verification without revealing the original password.

Second, when the user is not found or the password is wrong, we return the exact same error message: "Invalid email or password." This is deliberate. If we returned "Email not found" for a missing user and "Incorrect password" for a wrong password, an attacker could use these different messages to discover which email addresses have accounts in the system. Using the same message for both cases prevents this information leakage.

Creating Protected Routes

Create routes/user.js:

const express = require('express');
const router = express.Router();
const authenticate = require('../middleware/authenticate');
const users = require('../data/users');

// ============================================================
// PUBLIC ROUTE: No authentication required
// GET /api/status
// ============================================================
router.get('/status', function(req, res) {
    res.json({
        status: 'API is running',
        timestamp: new Date().toISOString(),
        message: 'This route is public and requires no authentication'
    });
});

// ============================================================
// PROTECTED ROUTE: Authentication required
// GET /api/profile
// ============================================================
router.get('/profile', authenticate, function(req, res) {
    // Because authenticate middleware ran first,
    // req.user is now available with the decoded token data

    const userId = req.user.userId;
    const user = users.findById(userId);

    if (!user) {
        return res.status(404).json({ error: 'User not found' });
    }

    res.json({
        success: true,
        user: {
            id: user.id,
            name: user.name,
            email: user.email,
            createdAt: user.createdAt
        }
    });
});

// ============================================================
// PROTECTED ROUTE: Update profile
// PUT /api/profile
// ============================================================
router.put('/profile', authenticate, function(req, res) {
    const userId = req.user.userId;
    const user = users.findById(userId);

    if (!user) {
        return res.status(404).json({ error: 'User not found' });
    }

    const { name } = req.body;

    if (!name || name.trim().length === 0) {
        return res.status(400).json({ error: 'Name cannot be empty' });
    }

    // Update the user's name
    user.name = name.trim();

    res.json({
        success: true,
        message: 'Profile updated successfully',
        user: {
            id: user.id,
            name: user.name,
            email: user.email
        }
    });
});

// ============================================================
// PROTECTED ROUTE: Get all users (admin-style listing)
// GET /api/users
// ============================================================
router.get('/users', authenticate, function(req, res) {
    // In a real app you might check req.user.role === 'admin' here

    res.json({
        success: true,
        message: 'This data is only visible to authenticated users',
        requestedBy: {
            userId: req.user.userId,
            email: req.user.email
        }
    });
});

module.exports = router;

The authenticate middleware is placed as the second argument to route handlers that require protection. Express calls middleware functions in order, so authenticate runs before the route handler function. If authentication fails, authenticate sends a response and the route handler never runs. If authentication succeeds, next() is called and the route handler runs with req.user populated.

Public routes simply do not include the authenticate middleware. The /api/status route has no authentication requirement and is accessible to anyone.

The Complete File Structure

Your project should look like this:

project/
    data/
        users.js
    middleware/
        authenticate.js
    routes/
        auth.js
        user.js
    .env
    server.js
    package.json

Part 6: Testing the Authentication Flow

Start the server:

node server.js

Now let us test each step using curl. If you prefer a graphical tool, Postman or Insomnia work equally well.

Step 1: Try Accessing a Protected Route Without a Token

curl http://localhost:3000/api/profile

Response:

{
    "error": "Access denied. No token provided.",
    "hint": "Include an Authorization header with format: Bearer <token>"
}

The protected route is inaccessible without authentication.

Step 2: Register a New User

curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice Johnson", "email": "alice@example.com", "password": "securepassword123"}'

Response:

{
    "success": true,
    "message": "Account created successfully",
    "user": {
        "id": 1,
        "name": "Alice Johnson",
        "email": "alice@example.com",
        "createdAt": "2024-01-15T10:30:00.000Z"
    }
}

Step 3: Log In

curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@example.com", "password": "securepassword123"}'

Response:

{
    "success": true,
    "message": "Login successful",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJuYW1lIjoiQWxpY2UgSm9obnNvbiIsImlhdCI6MTcwMzAwMTIzNCwiZXhwIjoxNzAzMDA0ODM0fQ.abc123signature",
    "expiresAt": "2024-01-15T11:30:00.000Z",
    "user": {
        "id": 1,
        "name": "Alice Johnson",
        "email": "alice@example.com"
    }
}

Copy the token value from the response.

Step 4: Access the Protected Route With the Token

curl http://localhost:3000/api/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Response:

{
    "success": true,
    "user": {
        "id": 1,
        "name": "Alice Johnson",
        "email": "alice@example.com",
        "createdAt": "2024-01-15T10:30:00.000Z"
    }
}

The protected route is now accessible with a valid token.

Step 5: Try Using a Tampered Token

Modify any character in the token and try again:

curl http://localhost:3000/api/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.MODIFIED.abc123signature"

Response:

{
    "error": "Invalid token. Please log in again."
}

The signature verification catches the tampering immediately.


Part 7: Role-Based Authorization

Authentication answers "who are you?" Authorization answers "what are you allowed to do?" Once you know who a user is, you can check their role or permissions to determine what they can access.

Adding role-based authorization with JWT is straightforward — include the role in the token payload, then check it in route-specific middleware:

// When creating the token at login, include the role
const token = jwt.sign(
    {
        userId: user.id,
        email: user.email,
        name: user.name,
        role: user.role    // Include role in the payload
    },
    process.env.JWT_SECRET,
    { expiresIn: '1h' }
);

Create a role-checking middleware in middleware/authorize.js:

JavaScript
// authorize is a function that returns middleware
// This pattern lets you pass the required role as an argument
function authorize(requiredRole) {
    return function(req, res, next) {

        // authorize must be used after authenticate
        // so req.user should already be populated
        if (!req.user) {
            return res.status(401).json({
                error: 'Authentication required'
            });
        }

        if (req.user.role !== requiredRole) {
            return res.status(403).json({
                error: 'Access denied. Insufficient permissions.',
                required: requiredRole,
                current: req.user.role
            });
        }

        next();
    };
}

module.exports = authorize;

Use both middleware together in routes:

const authenticate = require('../middleware/authenticate');
const authorize = require('../middleware/authorize');

// Public: anyone can access
router.get('/public-data', function(req, res) {
    res.json({ data: 'This is public' });
});

// Protected: any authenticated user can access
router.get('/user-data', authenticate, function(req, res) {
    res.json({ data: 'Authenticated users only', user: req.user.name });
});

// Restricted: only admins can access
router.get('/admin-data', authenticate, authorize('admin'), function(req, res) {
    res.json({ data: 'Administrators only', requestedBy: req.user.email });
});

// Delete user: only admins
router.delete('/users/:id', authenticate, authorize('admin'), function(req, res) {
    const targetId = parseInt(req.params.id, 10);

    // Prevent admins from deleting themselves
    if (targetId === req.user.userId) {
        return res.status(400).json({ error: 'Cannot delete your own account' });
    }

    // Proceed with deletion
    res.json({ success: true, message: 'User deleted', deletedId: targetId });
});

The middleware chain authenticate, authorize('admin') ensures that a request must first have a valid token, and then the authenticated user must have the admin role. Both conditions must be true.


Part 8: Applying Authentication Across an Entire Router

Sometimes you want every route in a router to require authentication, rather than adding the middleware to each route individually. Express allows you to apply middleware to an entire router:

const express = require('express');
const router = express.Router();
const authenticate = require('../middleware/authenticate');

// Apply authentication to ALL routes in this router
// Every route defined after this line requires a valid token
router.use(authenticate);

// All of these now require authentication automatically
router.get('/dashboard', function(req, res) {
    res.json({ message: 'Dashboard data', user: req.user.name });
});

router.get('/settings', function(req, res) {
    res.json({ message: 'User settings', userId: req.user.userId });
});

router.post('/settings', function(req, res) {
    res.json({ message: 'Settings updated' });
});

router.get('/notifications', function(req, res) {
    res.json({ message: 'Notifications list', userId: req.user.userId });
});

module.exports = router;

Using router.use(authenticate) before defining any routes means every route in that router automatically requires authentication. This is cleaner than repeating the middleware on every route and reduces the risk of accidentally leaving a route unprotected.


Part 9: Token Expiration and Refresh Strategy

JWTs expire. This is intentional and important. If a token is stolen, expiration limits the window during which it can be used. But expiration also means users get logged out, which is inconvenient.

Understanding Expiration

When you set expiresIn: '1h', the JWT library adds an exp claim to the payload with a Unix timestamp representing one hour from now. When jwt.verify() runs, it checks the current time against exp. If the current time is past exp, it throws a TokenExpiredError.

The Refresh Token Pattern

A common solution is to use two tokens: an access token and a refresh token.

The access token is short-lived — minutes to a few hours. It is what gets sent with every request. Because it expires quickly, the window of risk if it is stolen is small.

The refresh token is long-lived — days, weeks, or months. It is stored securely (often in an httpOnly cookie) and used only to request new access tokens. It is not sent with every request, reducing its exposure.

// When logging in, issue both tokens
router.post('/login', async function(req, res) {
    // ... credential verification ...

    // Short-lived access token (15 minutes)
    const accessToken = jwt.sign(
        { userId: user.id, email: user.email },
        process.env.JWT_SECRET,
        { expiresIn: '15m' }
    );

    // Long-lived refresh token (7 days)
    const refreshToken = jwt.sign(
        { userId: user.id },
        process.env.JWT_REFRESH_SECRET,    // Different secret for refresh tokens
        { expiresIn: '7d' }
    );

    // In a real app, store the refresh token in the database
    // so you can invalidate it if needed

    res.json({
        success: true,
        accessToken: accessToken,
        refreshToken: refreshToken
    });
});

// Endpoint to get a new access token using a refresh token
router.post('/refresh', function(req, res) {
    const { refreshToken } = req.body;

    if (!refreshToken) {
        return res.status(401).json({ error: 'Refresh token required' });
    }

    try {
        // Verify the refresh token using the refresh secret
        const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);

        // In a real app, check that this refresh token exists in the database
        // and has not been revoked

        // Issue a new access token
        const newAccessToken = jwt.sign(
            { userId: decoded.userId },
            process.env.JWT_SECRET,
            { expiresIn: '15m' }
        );

        res.json({
            success: true,
            accessToken: newAccessToken
        });

    } catch (error) {
        return res.status(401).json({
            error: 'Invalid or expired refresh token. Please log in again.'
        });
    }
});

The client uses the access token for API requests. When the access token expires, instead of forcing the user to log in again, the client uses the refresh token to silently get a new access token. Only when the refresh token itself expires (after days or weeks) does the user need to log in again.


Part 10: Common Security Considerations

Building authentication means taking on responsibility for user security. Here are the most important practices to follow.

Keep the Secret Key Secret

Your JWT secret is the foundation of your entire authentication system. If it is compromised, every token ever issued by your application can be forged. Never:

  • Commit it to version control

  • Include it in client-side code

  • Log it

  • Share it over insecure channels

Store it in environment variables, use a secrets management service in production, and generate it as a long random string:

// Generating a secure random secret (run this once, save the output)
const crypto = require('crypto');
console.log(crypto.randomBytes(64).toString('hex'));

Always Verify Before Trusting

Never decode a JWT without verifying it. Decoding reads the payload without checking the signature. Verifying both decodes and validates. Always use jwt.verify(), never jwt.decode(), for authentication purposes.

Set Reasonable Expiration Times

Tokens that never expire are a security risk. If a token is stolen, it remains valid forever. Set expiration times that balance security with user convenience. Common choices:

Access tokens:   15 minutes to 1 hour
Refresh tokens:  7 days to 30 days
Password reset:  15 to 30 minutes
Email verification: 24 hours

Do Not Store Sensitive Data in Tokens

The payload of a JWT is readable by anyone who has the token. Never include passwords, credit card numbers, social security numbers, or other sensitive information in the payload. Include only what is necessary to identify the user and their permissions.

Use HTTPS in Production

Tokens travel as plain text in HTTP headers. Without HTTPS, anyone who can intercept network traffic can read the token and use it. Always use HTTPS in production environments to encrypt the connection.


Summary

Authentication is the process of verifying identity. Without it, any user can access anything in your application.

Session-based authentication stores user state on the server and gives clients a session ID cookie. This requires shared session storage across multiple servers and makes the system stateful.

Token-based authentication gives clients a self-contained token after login. The server stores nothing. Each request includes the token, and the server verifies it independently. This stateless approach scales naturally.

JWT is the most widely used token format. It consists of three Base64URL-encoded parts separated by dots: a header describing the token type and algorithm, a payload containing claims about the user, and a signature created by hashing the header and payload with a secret key.

The login flow is: user submits credentials, server verifies them, server creates a signed JWT with user information embedded, server sends the token to the client, client stores the token, client sends the token in the Authorization header with every subsequent request, server verifies the signature and expiration on each request.

The authenticate middleware reads the Authorization header, extracts the token, calls jwt.verify() with the secret, and either attaches the decoded user to req.user and calls next(), or responds with 401 Unauthorized.

Route protection is achieved by placing the authenticate middleware before route handlers. Routes without the middleware are public. Routes with it require a valid token. Role-based authorization adds another check after authentication, examining req.user.role to determine what the authenticated user is allowed to do.