Build a Secret Notes API with JWT

Build a REST API that issues and verifies JWTs to protect private notes.

Introduction

30 Second Summary

Every time you log in to an app, something proves to the server that you are who you say you are. That something is often a token. A small, signed string that travels with every request you make.

In this project, you will build a "Secret Notes" API that issues JSON Web Tokens on login and rejects any request that does not carry a valid token. You will sign tokens, verify them, protect routes, and see rejection happen firsthand.

What You'll Build

You will have a running local API where you sign up, log in, get a token, and use it to store private notes that nobody else can read.

By the end of this project, you'll have:

  • A signup and login flow that issues a signed JWT when you enter valid credentials.
  • A protected notes endpoint that returns your private notes only when you present a valid token.
  • Firsthand proof that the API rejects requests when the token is missing, tampered with, or expired.
  • Secret Mission: Add token expiration handling and watch a token die in real time.

Are there any prerequisites?

None. This guide includes setup instructions for every tool you need, including Node.js and VS Code.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters. Taking a moment to commit to the goal makes the hands-on work that follows stick.

Set Up the Project

To build the Secret Notes API, you need a JavaScript runtime that can execute your server code and a few packages that handle routing, token signing, and environment configuration.

In this step, you will install Node.js, create your project folder, install the required dependencies, and wire up a basic server to confirm everything is connected.

In this step, get ready to:
  • Install Node.js v24 LTS.
  • Create the project folder and install dependencies.
  • Create the server files and start the API.
Install Node.js v24 LTS

Node.js is the runtime that lets you run JavaScript outside a browser. Your API server will run on it.

  • Open your browser and go to the Node.js downloads page.
  • Make sure v24 (LTS) is selected.
  • Download the macOS installer (.pkg file).
  • Open the downloaded file and follow the on-screen prompts to install.

What is Node.js?

Node.js is a JavaScript runtime built on Chrome's V8 engine. It lets you write server-side applications in JavaScript instead of needing a separate language like Python or Java for your backend.

Once the installer finishes, verify Node.js is working by checking its version.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check your Node.js version by running this command:
node -v

✔️ I see version 24 or higher

You should see something like v24.18.0 (or any v24.x.x number). Node.js is ready to go.

  • Confirm npm (Node's package manager) is also installed by running:
npm -v

You should see a version number like 11.16.0. Both tools are ready.

ⓧ I see an older version

If your version starts with v22 or lower, you need to upgrade to v24.

  • Go to the Node.js downloads page and download the v24 (LTS) macOS installer.
  • Run the .pkg installer. It will replace your older version.
  • Close and reopen Terminal, then run node -v again to confirm you see v24.

ⓧ Command not found

Node.js is not installed yet. Follow these steps to install it.

  • Go to the Node.js downloads page.
  • Make sure v24 (LTS) is selected.
  • Download the macOS installer (.pkg file).
  • Open the downloaded file and follow the on-screen prompts.
  • Close and reopen Terminal, then run node -v to confirm installation.
Create the project folder and install dependencies

Your project needs its own folder and a handful of packages. You will use npm (Node Package Manager) to initialise the project and install three libraries: Express for routing, jsonwebtoken for signing tokens, and dotenv for reading environment variables.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir secret-notes-api
cd secret-notes-api
  • Initialise a new Node.js project by running:
npm init -y

You should see a JSON block printed in your terminal showing the generated package.json contents.

  • Install the three project dependencies by running:
npm install express@5.2.1 jsonwebtoken@9.0.3 dotenv@17.4.2

You should see output showing the packages were added successfully.

What are these packages?

  • express is a web framework that handles HTTP requests and routes them to your code.
  • jsonwebtoken signs and verifies JWTs (the tokens you will issue to users).
  • dotenv loads secret values from a .env file so you never hardcode them.
  • Confirm the folder structure exists by running:
ls

You should see node_modules, package.json, and package-lock.json listed.

Seeing permission errors?

If npm install fails with a permission error, try running it with sudo in front. If it says the package version was not found, double-check you typed the exact version numbers above.

Need more help? Why is npm install failing with permission errors?

Create the server files and start the API

Now you will create the actual files that make up your API. First is server.js, the entry point that starts your web server and responds to incoming requests.

  • Open the secret-notes-api folder in VS Code. In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to Desktop, select the secret-notes-api folder, then click Open.
  • Create a new file by clicking the new file icon in the Explorer sidebar (or press Cmd+N).
  • Name the file server.js and paste the following code into it:
const express = require('express');
require('dotenv').config();

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

// Respond with a welcome message at the root URL
app.get('/', (req, res) => {
  res.json({ message: 'Welcome to the Secret Notes API' });
});

// Start the server on the port from .env (or default to 3000)
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

What does this code do?

  • require('express') loads the Express framework so you can create routes.
  • require('dotenv').config() reads your .env file and makes its values available via process.env.
  • app.use(express.json()) tells Express to automatically parse JSON request bodies.
  • app.get('/') defines a route that responds with a welcome message when someone visits the root URL.
  • app.listen(PORT) starts the server and prints a confirmation to your terminal.
  • Save server.js (Cmd+S).
  • Test that the server starts by running it directly. Open the VS Code integrated terminal (Ctrl+`) and run:
node server.js

You should see Server running on http://localhost:3000 printed in your terminal. The server is alive.

  • Stop the server by pressing Ctrl+C in the terminal.

Next, create the environment config file and set up the npm start shortcut.

  • Create a new file called .env in the root of your secret-notes-api folder and paste this into it:
JWT_SECRET=my-super-secret-jwt-key-change-this-in-production
PORT=3000

What is the .env file for?

The .env file holds configuration values that should not be hardcoded in your source code. JWT_SECRET is the secret key used to sign tokens. PORT tells the server which port to listen on.

  • Save .env (Cmd+S).
  • Now open package.json in the editor and replace its entire contents with the following:
{
  "name": "secret-notes-api",
  "version": "1.0.0",
  "description": "A Secret Notes API with JWT authentication",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "dotenv": "17.4.2",
    "express": "5.2.1",
    "jsonwebtoken": "9.0.3"
  }
}

What changed in package.json?

You replaced the auto-generated defaults with a clean version that sets main to server.js and adds a start script. Now running npm start will execute node server.js for you.

  • Save package.json (Cmd+S).

Before you run the next command, what do you think will happen when you type npm start?

  • Start the server using your new script by running:
npm start

You should see Server running on http://localhost:3000 printed in the terminal. Your project setup is complete.

Server not starting?

If you see Missing script: "start", make sure you saved package.json after pasting the new contents. The scripts section must include "start": "node server.js".

If you see Cannot find module 'express', confirm you are inside the secret-notes-api folder (run pwd to check) and that node_modules exists (run ls).

Still stuck? Help me debug why npm start is failing in my secret-notes-api project.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here are all three files as they should look right now.

const express = require('express');
require('dotenv').config();

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

app.get('/', (req, res) => {
  res.json({ message: 'Welcome to the Secret Notes API' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});
JWT_SECRET=my-super-secret-jwt-key-change-this-in-production
PORT=3000
{
  "name": "secret-notes-api",
  "version": "1.0.0",
  "description": "A Secret Notes API with JWT authentication",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "dotenv": "17.4.2",
    "express": "5.2.1",
    "jsonwebtoken": "9.0.3"
  }
}

Your environment is ready and your server is running. Next up, you will add API routes that serve notes to anyone who asks for them.

Build an Open Notes API

Your server is running and responding to requests. Now it's time to give it something real to serve.

You'll add a notes endpoint that returns data to anyone who asks. No login, no credentials, no questions asked. By the end of this step, you'll see exactly why that's a problem.

In this step, get ready to:
  • Add sample notes data to your server.
  • Create a route that serves all notes as JSON.
  • Test that anyone can access the notes without credentials.
Add sample notes to your server

Your API needs data to serve. You'll add a hardcoded array of notes directly in server.js so the server has something to return when someone asks for notes.

  • In VS Code, open server.js from the Explorer sidebar.
  • Find the line that says app.use(express.json()); and add the following code below it:
const notes = [
  { id: 1, text: 'Buy groceries', owner: 'demo' },
  { id: 2, text: 'Finish JWT project', owner: 'demo' },
  { id: 3, text: 'Call the bank', owner: 'demo' }
];

What does this code do?

This creates a notes array that acts as your in-memory database. Each note has three fields: an id for identification, text for the note content, and owner to track who wrote it.

Since the data lives in memory, it resets every time the server restarts. That's fine for learning how APIs work.

  • Save server.js.
Create the GET /notes route

The notes array exists, but nobody can reach it yet. You need a route that listens for requests and responds with the notes data.

  • In server.js, find the existing app.get('/', ...) route (the one that returns the welcome message).
  • Add the following code directly below that route's closing });:
app.get('/notes', (req, res) => {
  res.json(notes);
});

What does this route do?

When someone sends a GET request to /notes, Express calls this handler. res.json(notes) converts the array to JSON and sends it back as the response.

Notice there's no check for who is asking. The route hands over all the notes to anyone.

  • Save server.js.
  • Switch back to the terminal where your server is running.
  • Press Ctrl+C to stop the server.
  • Restart the server by running:
npm start

You should see Server running on http://localhost:3000 in the terminal.

Server won't start?

  • If you see a syntax error, check that your notes array has square brackets [] and each object uses curly braces {} with commas between entries.
  • If you see EADDRINUSE, the previous server is still running. Make sure you pressed Ctrl+C before restarting.

Still stuck? Help me fix my server.js so the notes route works

Test that anyone can access your notes

Your route is live. Time to prove that it works by sending a request as a completely anonymous user with no credentials.

  • Open a new terminal tab by pressing Cmd+T (macOS) or Ctrl+Shift+T (Windows).

Before you run the next command, take a guess. What do you think will happen when you request /notes without logging in or providing any credentials?

  • Request your notes endpoint by running:
curl http://localhost:3000/notes

You should see all three notes returned as JSON, something like:

[{"id":1,"text":"Buy groceries","owner":"demo"},{"id":2,"text":"Finish JWT project","owner":"demo"},{"id":3,"text":"Call the bank","owner":"demo"}]

Every single note came back. No login required. No identity check. That's the security gap you'll close with JWT authentication in the next steps.

Seeing 'Cannot GET /notes' or connection refused?

  • Make sure your server is still running in the other terminal tab. You should see the Server running message there.
  • Check that you added the app.get('/notes', ...) route before the app.listen() line. Routes registered after the server starts listening won't work.
  • If you see the welcome message instead of notes, double-check the URL ends with /notes and not just /.

Need more help? Help me debug why curl isn't returning my notes

✔️ Awesome, I've got everything!

Great. Double check you've saved server.js and your server is still running.

ⓧ I'd like to double check the full code

Here's the complete server.js file at this point:

const express = require('express');
require('dotenv').config();

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

const notes = [
  { id: 1, text: 'Buy groceries', owner: 'demo' },
  { id: 2, text: 'Finish JWT project', owner: 'demo' },
  { id: 3, text: 'Call the bank', owner: 'demo' }
];

app.get('/', (req, res) => {
  res.json({ message: 'Welcome to the Secret Notes API' });
});

app.get('/notes', (req, res) => {
  res.json(notes);
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

You've built a working notes endpoint. The problem is clear: anyone with the URL can read every note without identifying themselves. Next, you'll add user signup and login so the API can start telling people apart.

Add User Signup and Login

Right now, your API hands over every note to anyone who asks. That is a problem.

Before you can lock things down, you need a way for users to identify themselves. In this step, you will add signup and login routes that issue a signed JSON Web Token on successful login.

In this step, get ready to:
  • Add user signup to register new users.
  • Add login that returns a signed JWT.
  • Test the flow and decode the token to see its three parts.
Add user storage and the signup route

Your server needs two new things before it can register users: the jsonwebtoken library (for signing tokens in the next substep) and an in-memory array to hold registered users.

  • Stop your running server by pressing Ctrl+C in the terminal where npm start is running.
  • In server.js, find this line at the top:
const express = require('express');
  • Add the following line directly below it:
const jwt = require('jsonwebtoken');
  • Now find the notes array (the one starting with const notes = [).
  • Add this line directly above it:
const users = [];

What do these lines do?

The jwt import loads the jsonwebtoken library so you can sign and verify tokens later.

The users array stores registered users in memory. This means data resets every time the server restarts, which is fine for learning.

Next, you need a route where new users can register. The signup route accepts a username and password, checks for duplicates, and stores the new user.

  • In server.js, find the app.get('/notes') route. Add the following code above it:
app.post('/signup', (req, res) => {
  const { username, password } = req.body;

  // Reject if either field is missing
  if (!username || !password) {
    return res.status(400).json({ error: 'Username and password are required' });
  }

  // Prevent duplicate usernames
  const existingUser = users.find(u => u.username === username);
  if (existingUser) {
    return res.status(409).json({ error: 'Username already exists' });
  }

  // Store the new user
  users.push({ username, password });
  res.status(201).json({ message: `User ${username} created successfully` });
});

What does this route do?

  • It pulls username and password from the request body.
  • It returns a 400 error if either field is missing.
  • It returns a 409 error if the username is already taken.
  • If everything is valid, it pushes the user into the users array and confirms with a 201 status.
  • Save server.js.
  • Restart your server by running:
npm start

You should see Server running on http://localhost:3000 in your terminal again.

  • Open a new terminal tab so you can run curl commands while the server stays running.
  • Test the signup route by running this command:
curl -X POST http://localhost:3000/signup -H "Content-Type: application/json" -d '{"username":"testuser","password":"secret123"}'

You should see a response confirming the user was created:

{"message":"User testuser created successfully"}

Seeing an error instead?

  • If you see Cannot POST /signup, make sure you placed the route above the app.get('/notes') line and saved the file.
  • If you see Username and password are required, check that your curl command includes the -H "Content-Type: application/json" header.

Still stuck? Help me debug my signup route

Create the login route

Users can register, but registration alone does not prove identity on future requests. The login route checks credentials and returns a signed JWT that the user can present to prove who they are.

  • Stop your server with Ctrl+C.
  • In server.js, find the app.get('/notes') route. Add the following code above it (below the signup route you just created):
app.post('/login', (req, res) => {
  const { username, password } = req.body;

  // Find a user that matches both username and password
  const user = users.find(u => u.username === username && u.password === password);
  if (!user) {
    return res.status(401).json({ error: 'Invalid username or password' });
  }

  // Sign a JWT containing the username
  const token = jwt.sign(
    { username: user.username },
    process.env.JWT_SECRET,
    { expiresIn: '1h' }
  );

  res.json({ token });
});

What does this route do?

  • It looks up a user in the users array that matches both the username and password.
  • If no match is found, it returns a 401 error.
  • If credentials are valid, it calls jwt.sign() to create a token containing the username as its payload.
  • The token is signed with your JWT_SECRET from .env and expires in 1 hour.
  • Save server.js.
  • Restart your server by running:
npm start

Since the server restart clears the in-memory users array, you need to sign up a fresh user before logging in.

  • In your second terminal tab, sign up a user by running:
curl -X POST http://localhost:3000/signup -H "Content-Type: application/json" -d '{"username":"testuser","password":"secret123"}'
  • Now log in with those same credentials by running:
curl -X POST http://localhost:3000/login -H "Content-Type: application/json" -d '{"username":"testuser","password":"secret123"}'

You should see a response containing a long string of characters. That is your signed JWT:

{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."}

Why does my token look different from the example?

Every token is unique because it includes the current timestamp in its payload. Your token will be longer than the example above and contain different characters. That is expected.

Getting 'Invalid username or password'?

  • Make sure you ran the signup curl command first. The server restart cleared all user data.
  • Check that the username and password in your login command match exactly what you used for signup.

Still stuck? Help me debug my login route

Decode your token at jwt.io

The string you received is not random gibberish. A JWT has three parts separated by dots: a header, a payload, and a signature. You can decode it to see what is inside.

  • Copy the full token value from your terminal (everything inside the quotes after "token":).
  • Open https://jwt.io in your browser.
  • Paste your token into the Encoded text area on the left side of the page.

The right side of the page decodes your token into three sections:

  • Header shows the algorithm (HS256) and token type (JWT).
  • Payload shows the data you signed: username, an iat (issued-at timestamp), and an exp (expiration timestamp).
  • Signature shows how the token was signed. This is what prevents tampering.

What are the three parts of a JWT?

The header declares how the token is signed. The payload carries your data (called claims). The signature proves the token has not been modified since it was issued.

Anyone can decode the header and payload (they are just base64-encoded). The signature is what makes the token trustworthy. Without the secret key, nobody can forge a valid signature.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js and your server is running.

ⓧ I'd like to double check the full code

Your server.js should now contain these sections in order. Scroll up and confirm each is present:

  • The require lines for express, jsonwebtoken, and dotenv.
  • The users empty array and the notes array with three sample notes.
  • The GET / route returning the welcome message.
  • The POST /signup route that validates, checks for duplicates, and stores the user.
  • The POST /login route that verifies credentials and returns a signed JWT.
  • The GET /notes route that returns all notes.
  • The app.listen call at the bottom.

You now have users signing up, logging in, and receiving a signed JWT. But there is a glaring problem. Your /notes endpoint is still wide open. Next, you will use the token to lock that door.

Protect Routes with JWT Middleware

Right now, your /notes endpoint hands over every note to anyone who asks. No login required, no identity check. That is the gap you close in this step.

You will write a middleware function that sits between incoming requests and your route handlers. It inspects the Authorization header, verifies the JWT, and either lets the request through or rejects it on the spot.

In this step, get ready to:
  • Create an authenticateToken middleware function that verifies JWTs.
  • Protect your notes routes so only authenticated users can access them.
  • Test that requests without a valid token get rejected.
Create the authenticateToken middleware

Express middleware functions receive three arguments: req, res, and next. They run before your route handler and can either pass control forward or stop the request early.

  • In server.js, add a comment // In-memory storage (resets when server restarts) on the line directly above const users = [];.
  • Find the line app.get('/', (req, res) => {. Add the following code directly above it:
// Middleware to verify JWT
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

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

  jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid or expired token' });
    }
    req.user = user;
    next();
  });
}

// Public routes

What does this code do?

  • authHeader.split(' ')[1] extracts the token from a header formatted as Bearer <token>. The [1] grabs only the token part after the space.
  • If no token exists, the function immediately returns a 401 status ("you need to identify yourself") and never calls next().
  • jwt.verify() checks the token's signature against your secret. If verification fails, you get a 403 ("I know who you claim to be, but this token is not valid").
  • If verification succeeds, the decoded payload is attached to req.user so the route handler knows who is making the request. Then next() passes control to the route.
  • Save server.js.
Apply the middleware to your notes routes

The middleware function exists, but no route uses it yet. You need to insert it into the routes you want to protect and update the route logic to filter notes by the authenticated user.

  • In server.js, find your existing GET /notes route. It currently looks like this:
app.get('/notes', (req, res) => {
  res.json(notes);
});
  • Delete that entire route (all three lines) and replace it with the following protected routes:
// Protected routes
app.get('/notes', authenticateToken, (req, res) => {
  const userNotes = notes.filter(note => note.owner === req.user.username);
  res.json(userNotes);
});

app.post('/notes', authenticateToken, (req, res) => {
  const note = {
    id: notes.length + 1,
    text: req.body.text,
    owner: req.user.username
  };
  notes.push(note);
  res.status(201).json(note);
});

What does this code do?

  • authenticateToken is passed as the second argument to each route. Express runs it before the route handler, acting as a gatekeeper.
  • GET /notes now filters the notes array to return only notes where owner matches req.user.username (the username decoded from the token).
  • POST /notes creates a new note and automatically ties it to the authenticated user. No one can create notes under someone else's name.
  • Save server.js.
  • Switch to the terminal where your server is running and press Ctrl+C to stop it.
  • Restart the server by running:
npm start

You should see Server running on http://localhost:3000 in your terminal.

Before you test, think about this: what do you expect will happen if you try to access /notes without sending a token?

  • Open a new terminal tab and run this command:
curl http://localhost:3000/notes

You should see this response:

{"error":"Access token required"}

A 401 rejection. The middleware blocked the request because no token was provided. That is exactly what you built it to do.

Still seeing all notes returned?

  • Make sure you restarted the server after saving. The old code is still running until you press Ctrl+C and run npm start again.
  • Check that authenticateToken appears as the second argument in your app.get('/notes', authenticateToken, ...) call.
  • Confirm you deleted the old app.get('/notes', ...) route entirely. Having two GET /notes definitions means Express uses whichever comes first.

Help me debug why my /notes route is not requiring a token.

Test the full protected flow

Your middleware is blocking unauthenticated requests. Now you need to prove that authenticated requests work correctly. Since restarting the server cleared the in-memory users array, you need to sign up and log in again to get a fresh token.

  • Sign up a new user by running:
curl -X POST http://localhost:3000/signup -H "Content-Type: application/json" -d '{"username":"testuser","password":"secret123"}'

You should see: {"message":"User testuser created successfully"}.

  • Log in to receive a token by running:
curl -X POST http://localhost:3000/login -H "Content-Type: application/json" -d '{"username":"testuser","password":"secret123"}'

You should see a JSON response containing a token field with a long string of characters. Copy that entire token value (without the quotes).

  • Access your protected notes by running this command, replacing YOUR_TOKEN with the token you just copied:
curl http://localhost:3000/notes -H "Authorization: Bearer YOUR_TOKEN"

You should see an empty array [] because testuser has no notes yet. The hardcoded notes belong to demo, not testuser.

Why an empty array?

The middleware verified your token and decoded username: "testuser" from it. The route then filtered notes to show only entries where owner matches "testuser". Since all existing notes belong to "demo", you see nothing. The filter is working perfectly.

  • Create a note for your user by running:
curl -X POST http://localhost:3000/notes -H "Content-Type: application/json" -H "Authorization: Bearer YOUR_TOKEN" -d '{"text":"My first secret note"}'

You should see your new note returned with an id, your text, and owner: "testuser".

  • Verify the note appears in your list by running the GET request again:
curl http://localhost:3000/notes -H "Authorization: Bearer YOUR_TOKEN"

You should now see an array containing your note: [{"id":4,"text":"My first secret note","owner":"testuser"}].

Getting a 403 Invalid or expired token?

  • Make sure you copied the full token string with no trailing spaces or missing characters.
  • Check that the header format is exactly Authorization: Bearer <token> with a single space between Bearer and the token.
  • If you restarted the server between logging in and testing, your token was signed with the same secret so it should still work. But if you changed JWT_SECRET in .env, old tokens become invalid. Log in again to get a fresh one.

Help me fix my Bearer token authentication.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js and confirmed both rejection (no token) and acceptance (valid token) are working.

ⓧ I'd like to double check the full code

Your server.js should contain these sections in order. Scroll up and confirm each is present:

  • Imports (express, jsonwebtoken, dotenv) and app.use(express.json()).
  • In-memory storage: users array and notes array with three hardcoded notes.
  • The authenticateToken middleware function (extracts token, verifies, attaches req.user).
  • Public routes: GET /, POST /signup, POST /login.
  • Protected routes: GET /notes and POST /notes (both with authenticateToken as the second argument).
  • Server listen on PORT.

Your API now rejects strangers and welcomes verified users. The middleware pattern you built is the same one used by production APIs everywhere. Next up, you will push this further in the Secret Mission by handling what happens when tokens expire.

Secret mission

Handle Token Expiration

In production, tokens expire to limit damage if stolen. Can you make your API tell the difference between an expired token and a completely invalid one, and respond to each case differently?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Running Node.js server on localhost:3000.
  • The secret-notes-api project folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with your Secret Notes API.

  • Your server continues running on localhost:3000. You can keep testing endpoints with curl.
  • Your project files remain in the secret-notes-api folder on your Desktop.
  • Node.js stays installed on your machine for future projects.

Pause - I'll come back to this later

Stop the running server to free up your terminal, but keep all project files so you can pick up where you left off.

  • Switch to the terminal where your server is running.
  • Press Ctrl+C to stop the server.
  • Your project files stay in ~/Desktop/secret-notes-api. When you want to resume, open a terminal in that folder and run npm start.

Delete - I don't want to use this again

Remove all project files and start fresh. This cannot be undone.

  • Switch to the terminal where your server is running.
  • Press Ctrl+C to stop the server.
  • Remove the project folder by running this command:
rm -rf ~/Desktop/secret-notes-api
  • Confirm the folder is gone by running:
ls ~/Desktop | grep secret-notes

You should see no output, confirming the folder has been deleted.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully authenticated REST API that signs, verifies, and rejects JSON Web Tokens.

You've learned how to:

  • Build a REST API from scratch with Express that handles user signup, login, and private note storage.
  • Sign JWTs with a secret key and verify them in middleware that protects routes from unauthenticated requests.
  • Experience JWT rejection firsthand by testing requests without a token, with an invalid token, and with an expired token.
  • Secret Mission: Handled token expiration with specific error messages and watched a token die in real time.

Ready to quiz yourself?