Build a Secure Node.js REST API with JWT Auth

LearnWebCraft Team
18 min read
secure REST API with Node.jsNode.js JWT authenticationExpress.jsAPI security

I remember the first "real" API I ever built. I was so proud. It had endpoints, it fetched data, it did... well, stuff. I pushed it to a server, sent the link to a friend, and felt like a proper developer. My friend’s response? "Cool. I can see everyone's data, by the way. And I can delete it."

My stomach just dropped. I'd built a house with no doors. No locks. Just a big, friendly "Come On In!" sign hanging on the front.

That experience taught me a lesson that sticks with me to this day: security isn't a feature you add later. It's the foundation you build everything on. Without it, all your clever logic and beautiful data structures are just waiting for a slight breeze to knock them over.

So today, we're going to build a fortress. We're going to create a secure REST API with Node.js, and our magical key to the kingdom will be JSON Web Tokens, or JWTs for short. This isn't just about theory; we're going to write the code, really dig into the why behind every line, and by the end, you'll have a solid, dependable authentication system that you can use in your own projects. Sound good? Let's get started.

The Game Plan: What Are We Actually Building?

Before we dive headfirst into the code, let's get a clear picture of our destination. We're going to build a simple little Express.js server with three core endpoints:

  1. /register (Public): A place for new users to sign up. Anyone can knock on this door.
  2. /login (Public): A place for existing users to sign in. If they provide the right credentials, we'll give them a shiny new JWT.
  3. /profile (Protected): A route that only logged-in users can access. If you try to visit this without a valid JWT, you'll be politely—or not so politely—shown the door.

Now, to keep things focused squarely on authentication, we're not going to mess around with a database. We'll just store our users in a simple in-memory array. Honestly, the principles are exactly the same whether you're using MongoDB, PostgreSQL, or a flock of carrier pigeons—the core logic of hashing passwords and verifying tokens remains completely unchanged.

Laying the Foundation: Project Setup

First things first, let's get our workshop set up. Pop open your terminal, create a new project directory, and hop inside.

mkdir node-jwt-api
cd node-jwt-api

Now, we'll initialize a new Node.js project. That -y flag just says "yes" to all the default questions because, let's be real, we're eager to get to the good stuff.

npm init -y

This creates a package.json file, which is basically the birth certificate for our project. Now, we need to install our tools—the dependencies that are going to do all the heavy lifting for us.

npm install express jsonwebtoken bcrypt dotenv

Let's quickly meet our new team of packages:

  • express: The undisputed king of Node.js web frameworks. It makes building servers and defining routes feel incredibly straightforward. It's the sturdy workbench we'll build everything on.
  • jsonwebtoken: The star of our show. This package will help us create and verify our JSON Web Tokens. Think of it as our official key-maker and lock-checker.
  • bcrypt: The silent, stoic bodyguard for our passwords. This library will take user passwords and hash them into an unreadable scramble. We never store plain-text passwords. Ever.
  • dotenv: A nifty little helper that lets us manage secret keys and other configuration stuff outside of our main code. It's like having a secret drawer for our most important notes.

Alright, the workshop's ready and our tools are on the table. Let's build something.

The First Spark: A Basic Express Server

Let's start by creating a file named index.js and getting a barebones server running. It's always a good idea to make sure the lights are on before you start wiring up the complex machinery.

// index.js
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

// This is a crucial piece of middleware.
// It tells Express to parse incoming request bodies as JSON.
app.use(express.json());

// A simple test route to make sure everything is working
app.get('/api', (req, res) => {
  res.json({ message: "Welcome to the API! It's working." });
});

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

Let's break this down real quick. We import express, create an app, and tell it to listen on a port (defaulting to 3000 for now). The most important line for what we're about to do is app.use(express.json());. Without this little piece of magic, our server wouldn't have a clue how to read the user data (like username and password) that we'll be sending in the body of our requests.

Go ahead and run this file from your terminal:

node index.js

If you see Server is running on port 3000 pop up, you're in business. You can even visit http://localhost:3000/api in your browser to see that welcome message. Beautiful.

The Registration Desk: Signing Up New Users

Now for the first real piece of functionality: user registration. This is where we get to meet our password bodyguard, bcrypt.

So, why do we need it? Well, imagine you stored a user's password as "password123" in your database. If a hacker ever gets access to that data, they have every user's password in plain text. It's an absolute disaster waiting to happen.

Instead, we use hashing. Hashing is a one-way street. You can turn "password123" into a long, garbled string like "$2b$10$AbC...", but you can't turn that string back into "password123". It's like turning a cow into a hamburger—there's just no going back.

Let's add the code for this. First, we'll need a place to store our users and we'll need to import bcrypt.

// At the top of index.js
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');

const app = express();
// ... rest of the server setup

// In-memory "database" for demonstration purposes
const users = [];

// User Registration Endpoint
app.post('/api/register', async (req, res) => {
  try {
    // We get the username and password from the request body
    const { username, password } = req.body;

    // A simple check to see if the user already exists
    const existingUser = users.find(user => user.username === username);
    if (existingUser) {
      return res.status(400).json({ message: "Username already taken." });
    }

    // Hash the password
    // The '10' is the saltRounds - a measure of how complex the hash is.
    const hashedPassword = await bcrypt.hash(password, 10);

    // Create a new user object
    const newUser = { username, password: hashedPassword };

    // "Save" the user
    users.push(newUser);

    console.log('User registered:', newUser);
    console.log('All users:', users);

    // Send a success response
    res.status(201).json({ message: "User created successfully!" });

  } catch (error) {
    res.status(500).json({ message: "Something went wrong." });
  }
});

Take a look at that bcrypt.hash(password, 10) line. It's asynchronous (await), which means it takes a moment to do its complex cryptographic work without blocking the rest of our application. It takes the user's plain-text password and churns out a secure hash. We then store that hash, not the original password.

Now, if our users array were to leak, all an attacker would see is a list of usernames and these long, nonsensical hashed passwords. Completely useless to them!

You might be tempted to look at that hashed password and panic. "How will I ever compare that to the user's password when they log in?" Don't worry. bcrypt's compare method does all the heavy lifting for us. Speaking of which...

The Front Door: Logging In and Getting a Token

Okay, so a user is registered. Now they want to log in. This is where the real magic begins. We need to:

  1. Find the user by their username.
  2. Compare the password they just sent with the hashed password we have stored.
  3. If they match, create a JWT and send it back to them. This token is their all-access pass for the rest of the API.

Here's how we'll use bcrypt again. We can't un-hash our stored password to see if it matches. But what bcrypt can do is take the new password attempt, hash it using the same secret "salt" that's embedded in the original hash, and see if the results match. It's absolutely brilliant.

And this is where we finally bring in jsonwebtoken.

// ... after the /register endpoint

// User Login Endpoint
app.post('/api/login', async (req, res) => {
  try {
    const { username, password } = req.body;

    // Find the user in our "database"
    const user = users.find(u => u.username === username);
    if (!user) {
      // We don't want to give away whether the username or password was wrong
      return res.status(401).json({ message: "Invalid credentials." });
    }

    // Compare the submitted password with the stored hash
    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      return res.status(401).json({ message: "Invalid credentials." });
    }

    // If credentials are correct, we'll generate a JWT
    // The payload can contain any data you want. Don't put sensitive info here!
    const payload = {
      username: user.username,
      // You might add user ID, roles, etc.
    };

    // THIS IS A SECRET - it should be long, complex, and stored in an environment variable
    const SECRET_KEY = 'your-super-secret-key-that-is-long-and-random';

    const token = jwt.sign(payload, SECRET_KEY, { expiresIn: '1h' }); // Token expires in 1 hour

    res.json({
      message: "Logged in successfully!",
      token: token
    });

  } catch (error) {
    res.status(500).json({ message: "Something went wrong." });
  }
});

Let's pause here, because jwt.sign() is the heart of this entire operation. It takes three arguments:

  • Payload: A plain JavaScript object with the user's information. This is the "contents" of the VIP pass. It says who the user is. Crucially, this data is not encrypted, just encoded. Anyone can read what's inside. So never, ever put sensitive data like passwords in the payload. A user ID or username is perfect.
  • Secret Key: This is the most important part. It's a secret string that only your server knows. This secret is used to create a unique digital signature for the token, proving it came from you.
  • Options: An object where you can set things like the expiration time (expiresIn). Setting an expiration is vital. You definitely don't want tokens to be valid forever.

When this code runs, it spits out a long, funny-looking string. That's the JWT. The user's browser will now take this token and send it back to us with every future request for a protected resource.

The Bouncer: Our Authentication Middleware

So, the user has a token. Now what? How do we actually check it?

We could add verification logic to every single protected route, but that would be messy and super repetitive. Instead, we'll use one of Express's most powerful features: middleware.

A middleware is just a function that sits between an incoming request and the final route handler. It gets to inspect the request, modify it, or decide to block it entirely. Our middleware will be the bouncer at the door of our protected routes. Its job is simple: check for a valid token. If it finds one, let the user in. If not, send 'em packing.

Let's write this function.

// ... anywhere before your protected routes

function authenticateToken(req, res, next) {
  // The token is typically sent in the 'Authorization' header
  // in the format: "Bearer TOKEN"
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Get the token part

  if (token == null) {
    // No token was sent
    return res.sendStatus(401); // Unauthorized
  }

  // Remember that secret key from the login? We need it again here.
  const SECRET_KEY = 'your-super-secret-key-that-is-long-and-random';

  jwt.verify(token, SECRET_KEY, (err, user) => {
    if (err) {
      // The token is no longer valid (expired, tampered with, etc.)
      return res.sendStatus(403); // Forbidden
    }

    // If the token is valid, the 'user' payload is decoded.
    // We attach it to the request object so our route handlers can access it.
    req.user = user;

    // We're done here, move on to the next function in the chain (the actual route handler)
    next();
  });
}

This is so cool. Let's walk through what's happening:

  1. It grabs the Authorization header from the request. The standard way to send tokens is as Bearer <token>.
  2. It splits that string to isolate the actual token itself.
  3. If there's no token, it sends a 401 Unauthorized status and stops everything right there.
  4. It then uses jwt.verify(). This is the inverse of jwt.sign(). It takes the token, our secret key, and a callback function.
  5. jwt.verify() does something amazing: it re-calculates the signature based on the token's header, payload, and our secret key. If the signature it calculates matches the signature on the token, it knows the token is legit and hasn't been messed with. If not, or if the token is expired, it throws an error.
  6. If everything checks out, the decoded payload (our user object from login) is passed to the callback. We then do a neat little trick: we attach this user object to the req object itself. This makes the user's data available to any subsequent middleware or route handlers.
  7. Finally, it calls next(), which tells Express, "Okay, this person is legit. Pass them on to the next stop"—which will be our protected route.

Protecting the Goods: The Profile Route

We've built the lock (jwt.verify) and the key (jwt.sign), and we've hired a bouncer (authenticateToken middleware). Now, let's actually put a door on one of our rooms.

Creating a protected route is now laughably easy. We just drop our middleware function right into the route definition, right before the main handler.

// ... after the middleware function

// This is our protected route
app.get('/api/profile', authenticateToken, (req, res) => {
  // If we reach this point, it means authenticateToken middleware has run and was successful.
  // We have access to the user payload that we attached in the middleware.
  res.json({
    message: `Welcome, ${req.user.username}! This is protected data.`,
    user: req.user
  });
});

And that's it! If a request comes in for /api/profile, Express first runs authenticateToken. If the token is missing or invalid, the middleware sends back a 401 or 403 response, and the request never even reaches the (req, res) => { ... } part. If the token is valid, next() gets called, and our final route handler runs, happily accessing the req.user data that the middleware so kindly provided.

See how we pass authenticateToken as a second argument? That's middleware in action. Express will run authenticateToken before the route handler. If the token is invalid or missing, the request stops. If it's valid, it continues, and req.user will be available.

A Quick but Critical Detour: Hiding Your Secrets

Remember that SECRET_KEY we hardcoded? Yeah, that's a terrible idea in a real application. If you commit that to a public GitHub repository, you've just given away the keys to your entire security system. It's a huge no-no.

This is where our little helper dotenv comes in. It lets us store these secrets in a special file that we don't commit to version control.

  1. Create a new file in your root directory called .env.

  2. Add your secret to it like this:

    JWT_SECRET="some-really-long-and-insanely-random-string-of-characters-123!@#"
    
  3. Now, create a .gitignore file (if you don't have one already) and add .env to it. This tells Git to completely ignore this file.

    node_modules
    .env
    
  4. Finally, at the very, very top of your index.js, you need to require and configure dotenv.

    // The very first line of index.js
    require('dotenv').config();
    
    const express = require('express');
    // ...
    

Now, you can access that secret anywhere in your app using process.env.JWT_SECRET. Let's quickly update our code to use this much safer method.

// In the login route:
const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '1h' });

// In the authenticateToken middleware:
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
  // ...
});

Much, much better. Now our code is clean of sensitive data, making it safe to share and collaborate on.

Putting It All Together: Testing with Postman

Theory is great, but let's see this system in action. The best way to test an API like this is with a tool like Postman or Insomnia.

Here's the workflow you'll follow:

  1. Register a User:

    • Make a POST request to http://localhost:3000/api/register.
    • In the Body tab, select raw and JSON.
    • Add your user details: { "username": "testuser", "password": "password123" }.
    • Hit "Send". You should get back a 201 Created response.
  2. Log In:

    • Make a POST request to http://localhost:3000/api/login.
    • Use the same Body as before.
    • Hit "Send". You should get a 200 OK response with a token in the body. Go ahead and copy that long token string.
  3. Try Accessing the Protected Route (Without the Token):

    • Make a GET request to http://localhost:3000/api/profile.
    • Don't add any special headers or anything.
    • Hit "Send". You should get a 401 Unauthorized error. See? Our bouncer is doing its job!
  4. Access the Protected Route (With the Token):

    • Make the same GET request to http://localhost:3000/api/profile.
    • This time, go to the Authorization tab (or the Headers tab).
    • For the type, select Bearer Token.
    • Paste your copied token into the token field.
    • Hit "Send". Boom! You should now get a 200 OK response with the welcome message: "Welcome, testuser! This is protected data."

It works! You've successfully built a secure, token-based authentication system from scratch.

Where Do We Go From Here?

We’ve built a really solid foundation here. We have a registration system that securely hashes passwords and a login system that issues time-limited access tokens. We have a middleware that protects our routes like a vigilant guard. This is a huge step.

From here, the world is your oyster. You could expand this by adding user roles to the JWT payload, then creating different middleware for different permission levels (like an isAdmin check). You could implement refresh tokens for a more seamless user experience, allowing them to stay logged in for longer without having to constantly re-enter their password.

But for now, take a moment to appreciate what you've built. You've moved beyond just making things work and into the realm of making things safe. And in the world of web development, that's a distinction that makes all the difference.


Frequently Asked Questions

Q: What's the difference between authentication and authorization?

A: Great question! Think of it this way: Authentication is the process of verifying who you are. When you log in with a username and password, you are authenticating. Authorization is the process of verifying what you are allowed to do. After you've logged in, the system might check if you're an 'admin' before letting you access a control panel. Our middleware handles authentication; we could easily extend it to handle authorization by checking for roles inside the JWT payload.

Q: Where should I store the JWT on the client-side (like in a React or Vue app)?

A: Ah, the great debate! The two main options are Local Storage and HTTP-Only Cookies. Local Storage is simpler to implement, but it is vulnerable to Cross-Site Scripting (XSS) attacks, where malicious code on a page could steal the token. An HTTP-Only cookie is more secure against XSS because JavaScript can't access it, but it can be vulnerable to Cross-Site Request Forgery (CSRF) attacks (which you can mitigate). For many applications, a secure, httpOnly, sameSite cookie is the recommended approach for its security benefits.

Q: My token expires after an hour. Does the user get logged out? How do I fix that?

A: Yep, once the token expires, they will be logged out. The professional way to handle this is with Refresh Tokens. When a user logs in, you issue both a short-lived access token (like ours, for maybe 15 minutes to an hour) and a long-lived refresh token (e.g., for 7 days). The refresh token is stored securely and can only be used at a special /refresh_token endpoint to get a new access token. This way, the user experience is smooth, but the powerful access token that's sent with every request remains short-lived and secure. It's a more advanced pattern, but a crucial one for production apps.

Q: Is JWT the only way to secure a Node.js API?

A: Not at all! The classic approach is session-based authentication, where the server creates a session ID, stores it in a cookie, and keeps a record of that session on the server-side (in memory or a database). This is more "stateful," while JWTs are "stateless" (the server doesn't need to remember the token, it just needs to verify it). Other options include OAuth 2.0 for third-party authentication (like "Login with Google") and API Keys for machine-to-machine communication. JWTs are incredibly popular for modern web and mobile apps because their stateless nature makes them easy to scale.