API Keys and Rate Limiting

Secure your URL shortener with API key auth and rate limiting.

Introduction

30 Second Summary

Every public API you have used required an API key and capped how many requests you could make per minute. Those guardrails protect services from abuse and ensure fair usage for everyone.

In this project, you will add API key authentication and rate limiting to the URL shortener you built in Part 1. You will also build a bulk endpoint that shortens up to 10 URLs in a single request.

What You'll Build

You will generate an API key, use it to shorten a batch of URLs in one request, and watch the rate limit headers tick down with every call you make.

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

  • A working API key system where you generate keys from the command line, send authenticated requests, and watch unauthorized calls get rejected with a 401.
  • Rate limiting that enforces a request budget per key, returning 429 Too Many Requests when exceeded and showing rate limit headers counting down with each call.
  • A bulk shortening endpoint where you post an array of URLs and get back all the short codes in one response.
  • An optional Secret Mission to push your skills further.

Are there any prerequisites?

This project is Part 2 of the URL Shortener series. You need a completed Part 1 project with a working Turso database, Vercel deployment, and the url-shortener code on your Desktop.

Before We Start

Before we dive in, let's lock in what you're about to build on top of your Part 1 URL shortener and why it matters.

Set Up the Rate Limiting Package and Database Schema

You've locked in what you're building and why it matters. Now it's time to lay the groundwork for securing your API.

Your URL shortener from Part 1 is completely open. Anyone who finds your Vercel URL can shorten as many links as they want with zero restrictions. Before you can add authentication or rate limiting, you need the right package installed and a database table ready to store API keys.

In this step, get ready to:
  • Install the express-rate-limit package.
  • Add an api_keys table to your database schema.
  • Create helper functions for generating and hashing API keys.
Install express-rate-limit

Your URL shortener needs a way to cap how many requests each user can make. The express-rate-limit package is the standard middleware for this in Express.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, usually above Tab).
  • Install the rate limiting package by running this command:
npm install express-rate-limit@8.6.2
  • You should see express-rate-limit added to your package.json dependencies in the terminal output.

What is express-rate-limit?

It is Express middleware that tracks how many requests each client makes within a time window. Once a client exceeds the limit, the middleware automatically responds with a 429 Too Many Requests status code. You will configure and apply it in a later step.

Seeing an npm error?

Make sure your terminal is in the url-shortener folder. VS Code's integrated terminal opens at your project root, but if you opened a separate terminal, you may need to navigate there first.

help me troubleshoot this npm install error

Add the api_keys table to your database initialization

Your Turso database currently has only a urls table. You need a second table to store API key hashes so the server can look up and validate keys on every request.

  • Open api/index.js in the VS Code editor.
  • Find the initDatabase function. Inside it, find the closing ); of the urls table creation.
  • Add this second table creation directly below it, still inside the initDatabase function:
  await db.execute(`
    CREATE TABLE IF NOT EXISTS api_keys (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      key_hash TEXT NOT NULL UNIQUE,
      key_prefix TEXT NOT NULL,
      name TEXT NOT NULL,
      created_at TEXT DEFAULT (datetime('now'))
    )
  `);

What do these columns store?

  • key_hash stores the SHA-256 hash of the API key. The raw key is never saved in the database.
  • key_prefix stores the first few characters (like us_4f2a) so you can identify a key without exposing it.
  • name is a human-readable label so you can tell your keys apart.
  • Save api/index.js.
  • Start the server to verify your new SQL is valid by running this command:
node api/index.js
  • You should see Server running on http://localhost:3000 with no errors. This confirms the api_keys table creation SQL is valid.
  • Stop the server by pressing Ctrl+C in the terminal.

Seeing a SQL syntax error?

Check that your new await db.execute(...) block is inside the initDatabase function, not outside it. Also make sure the backtick template literal is closed properly with `); at the end.

help me fix this SQL error in my initDatabase function

Add the crypto import and helper functions

API keys need to be generated securely and stored as hashes, not as raw text. Node.js has a built-in crypto module that handles both of these tasks without any extra packages.

  • In api/index.js, find the line const path = require("path"); near the top of the file.
  • Add this import on the line directly after it:
const crypto = require("crypto");
  • Now find the line const { nanoid } = require("nanoid");.
  • Add this import on the line directly after it:
const { rateLimit } = require("express-rate-limit");
  • Scroll down past the const db = createClient({...}) block that sets up your Turso connection.
  • Add these two helper functions on the lines after the closing }); of the db client:
// Generate a cryptographically secure API key with a "us_" prefix
function generateApiKey() {
  return "us_" + crypto.randomBytes(24).toString("hex");
}

// Hash the key with SHA-256 so the raw key is never stored
function hashApiKey(key) {
  return crypto.createHash("sha256").update(key).digest("hex");
}

What do these functions do?

  • generateApiKey() uses crypto.randomBytes(24) to create 24 bytes of cryptographically secure randomness, then converts them to a 48-character hex string. The us_ prefix makes keys easy to identify at a glance, similar to how Stripe uses sk_ for their keys.
  • hashApiKey() runs SHA-256 on the key so only the hash is stored in the database. If someone gets access to your database, they cannot reverse the hashes back into working keys.
  • The rateLimit import is not used yet. You will configure it in a later step to enforce request limits.
  • Save api/index.js.

Before you start the server, what do you expect to happen? Will the new imports and functions cause any errors, or will the server start normally?

  • Restart the server to confirm everything works by running this command:
node api/index.js

You should see Server running on http://localhost:3000 with no errors. The api_keys table is silently created in your Turso database in the background.

Server not starting?

  • If you see Cannot find module for express-rate-limit, make sure the npm install from substep 1 completed successfully.
  • If you see a syntax error, check that the crypto and rateLimit import lines each end with a semicolon and are on their own separate lines.
  • If port 3000 is already in use, you may have a previous server still running. Press Ctrl+C first, then try again.

help me debug why my server is not starting

✔️ Awesome, I've got everything!

Great. Double check you've saved api/index.js and your server is running with no errors.

ⓧ I'd like to double check the full code

Scroll through your api/index.js and confirm these blocks are in place, in order:

  • const crypto = require("crypto"); on the line after the path import.
  • const { rateLimit } = require("express-rate-limit"); on the line after the nanoid import.
  • The generateApiKey() and hashApiKey() functions below the db client setup.
  • The CREATE TABLE IF NOT EXISTS api_keys statement inside initDatabase, after the urls table creation.

Your foundation is in place. You have a database table ready to store API key hashes, and helper functions that can generate and hash keys on demand. Next, you'll put these tools to work by building the endpoint that creates real API keys.

Generate API Keys

Your database schema and helper functions are ready. You have a generateApiKey function that creates a random key and a hashApiKey function that hashes it with SHA-256.

Those helpers do nothing until an endpoint calls them. Right now your API has no concept of keys at all, so anyone who finds your URL can shorten links without limits. You need a route that issues keys and stores them securely.

In this step, get ready to:
  • Add a POST /api/keys endpoint that generates, hashes, and stores API keys.
  • Generate your first API key and prove that the API still works without one.
Add the POST /api/keys endpoint

This endpoint is the "sign up" flow for your API. When someone sends a POST request with a name, the server generates a cryptographically secure key, hashes it, stores only the hash in Turso, and returns the raw key exactly once.

  • In api/index.js, find the initDatabase(); call.
  • Add the following route below it, before the existing app.post("/api/shorten", ...) route:
app.post("/api/keys", async (req, res) => {
  const { name } = req.body;

  // Require a name to identify this key
  if (!name) {
    return res
      .status(400)
      .json({ error: "A name for this API key is required" });
  }

  // Generate a secure key, hash it, and extract the prefix
  const apiKey = generateApiKey();
  const keyHash = hashApiKey(apiKey);
  const keyPrefix = apiKey.substring(0, 7);

  try {
    // Store only the hash in the database, never the raw key
    await db.execute({
      sql: "INSERT INTO api_keys (key_hash, key_prefix, name) VALUES (?, ?, ?)",
      args: [keyHash, keyPrefix, name],
    });

    res.status(201).json({
      apiKey,
      prefix: keyPrefix,
      name,
      message: "Save this key now. You will not be able to see it again.",
    });
  } catch (error) {
    res.status(500).json({ error: "Failed to create API key" });
  }
});

What does this code do?

This route handles the full lifecycle of issuing an API key.

  • generateApiKey() creates a random string prefixed with us_ so you can identify it at a glance, just like Stripe uses sk_ for its keys.
  • hashApiKey() runs the key through SHA-256 before storing it. The database never sees the raw key.
  • keyPrefix saves the first 7 characters (like us_a3f1) so you can identify a key later without exposing the full value.
  • The response returns the raw key exactly once. After this moment, nobody can retrieve it.
  • Save api/index.js.
  • Switch back to your terminal and stop the running server by pressing Ctrl+C.
  • Restart the server by running:
node api/index.js

You should see Server running on http://localhost:3000 with no errors.

Seeing a syntax error on startup?

  • Check that the new route starts with app.post("/api/keys" and ends with }); on its own line. A missing closing brace or parenthesis is the most common cause.
  • Make sure the new route is placed after initDatabase(); and before the app.post("/api/shorten", ...) route. Placing it inside another route's braces causes nesting errors.
  • help me debug a syntax error when starting my Express server after adding the /api/keys route

✔️ Awesome, I've got everything!

Double check you have saved api/index.js and the server is running.

ⓧ I'd like to double check the full code

Your api/index.js file is getting long, so scroll through and confirm these sections are present in order:

  • Imports at the top: dotenv, express, path, crypto, @libsql/client, nanoid, express-rate-limit.
  • App setup, middleware, and database client.
  • generateApiKey() and hashApiKey() helper functions.
  • initDatabase() creating both urls and api_keys tables, followed by the initDatabase(); call.
  • The new app.post("/api/keys", ...) route you just added.
  • The existing /api/shorten, /api/urls, and /:shortCode routes from Part 1.
  • The app.listen and module.exports at the bottom.
Generate a key and test the open API

The endpoint is live. Time to issue your first API key and then test whether the rest of your API actually requires it.

  • Open a second terminal window so you can run commands while the server keeps running.
  • Generate your first API key by running this command:

macOS

curl -X POST http://localhost:3000/api/keys \
  -H "Content-Type: application/json" \
  -d '{"name": "my-first-key"}'

Windows

curl.exe -X POST http://localhost:3000/api/keys `
  -H "Content-Type: application/json" `
  -d '{\"name\": \"my-first-key\"}'

Why curl.exe instead of curl?

PowerShell aliases curl to its own Invoke-WebRequest command. Using curl.exe calls the real curl binary instead.

What should I see?

You should see a JSON response containing your raw API key (starting with us_), the key prefix, the name you provided, and a message reminding you to save the key.

  • Copy the full apiKey value from the response and paste it somewhere safe, like a text file or a notes app. You will need it throughout this project.

Why can I only see the key once?

The database stores only the SHA-256 hash of the key. Hashing is a one-way operation, so there is no way to reverse the hash back into the original key. This is the same pattern that production APIs like Stripe and GitHub use.

You have a key now. But does your API actually check for it? Before you run the next command, predict what will happen: if you try to shorten a URL without providing any API key, will the request succeed or fail?

  • Test the shorten endpoint without any API key by running:

macOS

curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}'

Windows

curl.exe -X POST http://localhost:3000/api/shorten `
  -H "Content-Type: application/json" `
  -d '{\"url\": \"https://example.com\"}'

It works. You get back a 201 response with a short code, no key required. Anyone who knows your URL can shorten links without any authentication at all.

Why does this work without a key?

You created a key and stored its hash, but none of your existing routes check for a key in incoming requests. The /api/shorten and /api/urls routes are still wide open. That is the gap you will close in the next step.

You have seen the problem first-hand: keys exist but nobody is checking for them. Next up, you will build the middleware that blocks every unauthenticated request and locks down your API for real.

Lock Down Your API with Authentication

In the last step, you generated your first API key and then proved the problem: your API happily shortens URLs without any key at all.

Now you will write middleware that intercepts every request, checks for a valid API key in the x-api-key header, and blocks anyone who does not have one. You will also update the web frontend so it sends the key with each request.

In this step, get ready to:
  • Write an authentication middleware function that validates API keys against your database.
  • Apply the middleware to your routes and verify that unauthenticated requests get a 401 response.
  • Update the frontend to send the API key with every request so the web interface keeps working.
Build the validateApiKey middleware

Express middleware functions sit between an incoming request and your route handler. They can inspect the request, modify it, or reject it entirely before the route code ever runs.

  • In api/index.js, find the hashApiKey function. Add the following validateApiKey function right below it:
// Middleware to check API key on every protected request
async function validateApiKey(req, res, next) {
  // Read the API key from the request header
  const apiKey = req.headers["x-api-key"];
  if (!apiKey) {
    return res.status(401).json({ error: "API key required", message: "Include your API key in the x-api-key header" });
  }
  // Hash the provided key and look it up in the database
  const keyHash = hashApiKey(apiKey);
  try {
    const result = await db.execute({ sql: "SELECT id, key_prefix, name FROM api_keys WHERE key_hash = ?", args: [keyHash] });
    if (result.rows.length === 0) return res.status(401).json({ error: "Invalid API key" });
    // Attach the key data to the request for downstream use
    req.apiKeyData = result.rows[0];
    next();
  } catch (error) { return res.status(500).json({ error: "Authentication error" }); }
}

What does this code do?

This middleware runs before any protected route handler. It reads the x-api-key header from the incoming request and checks whether a key was provided at all.

If a key exists, the middleware hashes it with the same hashApiKey function you wrote earlier and looks up the hash in the api_keys table. A match means the key is valid, so the middleware attaches the key data to req.apiKeyData and calls next() to let the request continue. No match means a 401 response stops the request cold.

  • Save api/index.js.
  • Stop the running server by pressing Ctrl+C in the terminal.
  • Restart the server by running:
node api/index.js

You should see Server running on http://localhost:3000 with no errors. The middleware function is defined but nothing is using it yet.

Seeing a syntax error on startup?

  • Make sure the validateApiKey function is placed after hashApiKey and before initDatabase. It depends on hashApiKey and db, which must be defined above it.
  • Check that all curly braces and parentheses are balanced. The try/catch block inside the function is easy to close incorrectly.
  • help me debug a syntax error in my validateApiKey middleware
Apply the middleware to your routes and test

The middleware function exists now, but nothing is using it. Express needs you to pass it as an argument to each route you want to protect.

  • In api/index.js, find this line:
app.post("/api/shorten", async (req, res) => {
  • Add validateApiKey as the second argument, so the line looks like this:
app.post("/api/shorten", validateApiKey, async (req, res) => {
  • Now find this line:
app.get("/api/urls", async (req, res) => {
  • Add validateApiKey here too:
app.get("/api/urls", validateApiKey, async (req, res) => {

Why does the redirect route stay public?

The GET /:shortCode route does not get the middleware. Anyone clicking a short link should be redirected to the original URL, whether or not they have an API key. Only the routes that create or list URLs need protection.

  • Save api/index.js.
  • Restart the server (Ctrl+C, then run again):
node api/index.js

Before you test, what do you think will happen if you send a request to /api/shorten without an API key now?

  • Test by sending a request without any API key:
curl -X POST http://localhost:3000/api/shorten -H "Content-Type: application/json" -d '{"url":"https://example.com"}'

You should see a 401 response with API key required. The middleware blocked the request before it reached your route handler.

  • Now test with your API key. Replace your-api-key-here with the key you saved from the last step:
curl -X POST http://localhost:3000/api/shorten -H "Content-Type: application/json" -H "x-api-key: your-api-key-here" -d '{"url":"https://example.com"}'

This time you get a 201 response with a new short URL. The middleware found your key in the database and let the request through.

Still seeing a 401 with your key?

  • Make sure you are using the raw key string (starts with us_), not the hashed version. The middleware hashes it for you before comparing.
  • Check that the header name is exactly x-api-key (lowercase, with hyphens). Header names are case-insensitive in HTTP, but the value must be correct.
  • If you generated a new key after restarting the server, use the new key. Old keys from before the api_keys table existed are not stored.
  • help me debug why my API key is returning 401

✔️ Awesome, I've got everything!

Great. Double check you have saved api/index.js and that your server restarted cleanly.

ⓧ I'd like to double check the full code

Your api/index.js is now too long to display in one block. Scroll up and confirm these pieces are all present, in order:

  • The require imports at the top (including crypto and rateLimit).
  • The generateApiKey and hashApiKey helper functions.
  • The validateApiKey middleware function (right after hashApiKey).
  • The initDatabase function with both urls and api_keys tables.
  • The POST /api/keys route (no middleware).
  • The POST /api/shorten route with validateApiKey as the second argument.
  • The GET /api/urls route with validateApiKey as the second argument.
  • The GET /:shortCode redirect route (no middleware).
Update the frontend to send the API key

Your API now rejects unauthenticated requests. But your web frontend does not send an API key with its requests yet. If you refresh the browser and try to shorten a URL, it will fail with a 401.

  • In public/index.html, find the closing </style> tag. Add these CSS styles right before it:
/* Style the API key input bar */
.api-key-group { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; padding: 1rem; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 0.5rem; align-items: center; }
.api-key-group input { flex: 1; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.5rem; font-size: 0.875rem; font-family: monospace; }
.api-key-group label { font-size: 0.875rem; color: #92400e; font-weight: 600; white-space: nowrap; }
/* Style the rate limit info and error messages */
.rate-info { font-size: 0.75rem; color: #6b7280; margin-top: 0.5rem; }
.error { background: #fef2f2; border: 1px solid #fca5a5; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1rem; color: #991b1b; display: none; }
  • In the <body>, find <h1>URL Shortener</h1>. Add these lines right after it:
<!-- API key input bar -->
<div class="api-key-group">
  <label>API Key:</label>
  <input type="text" id="apiKeyInput" placeholder="Paste your API key here...">
</div>
<!-- Error message display -->
<div class="error" id="error"></div>
  • Find <div class="result" id="result">. Inside that div, add this line after the <a> tag:
    <div class="rate-info" id="rateInfo"></div>

What are these new elements?

The api-key-group div creates a yellow input bar where the learner pastes their API key. The error div displays error messages from the API (like the 401 you just saw). The rate-info div will show remaining rate limit information once you add rate limiting in the next step.

  • Save index.html and refresh your browser at http://localhost:3000.

You should see a yellow API key input bar at the top of the page. The input field is visible, but the JavaScript still needs updating so it actually sends the key with requests.

  • Select all content between <script> and </script> in index.html. Delete it and replace it with this new script, starting with the element references and API key setup:
    // Cache DOM element references
    const apiKeyInput = document.getElementById("apiKeyInput");
    const urlInput = document.getElementById("urlInput");
    const aliasInput = document.getElementById("aliasInput");
    const shortenBtn = document.getElementById("shortenBtn");
    const resultDiv = document.getElementById("result");
    const shortLink = document.getElementById("shortLink");
    const rateInfo = document.getElementById("rateInfo");
    const urlList = document.getElementById("urlList");
    const errorDiv = document.getElementById("error");

    // Restore any previously saved API key from localStorage
    const savedKey = localStorage.getItem("apiKey");
    if (savedKey) apiKeyInput.value = savedKey;

    // Save the API key whenever the user changes it
    apiKeyInput.addEventListener("change", () => {
      localStorage.setItem("apiKey", apiKeyInput.value.trim());
    });

    // Build headers with the API key for every fetch request
    function getHeaders() {
      const headers = { "Content-Type": "application/json" };
      const key = apiKeyInput.value.trim();
      if (key) headers["x-api-key"] = key;
      return headers;
    }
  • Continue pasting right below, adding the shortenUrl function:
    // Shorten a URL using the authenticated API
    async function shortenUrl() {
      const url = urlInput.value.trim();
      if (!url) return;
      const alias = aliasInput.value.trim();
      errorDiv.style.display = "none";
      resultDiv.style.display = "none";
      try {
        const body = { url };
        if (alias) body.alias = alias;
        const response = await fetch("/api/shorten", { method: "POST", headers: getHeaders(), body: JSON.stringify(body) });
        const data = await response.json();
        if (!response.ok) { errorDiv.textContent = data.error; errorDiv.style.display = "block"; return; }
        shortLink.href = data.shortUrl;
        shortLink.textContent = data.shortUrl;
        resultDiv.style.display = "block";
        const remaining = response.headers.get("RateLimit-Remaining");
        if (remaining !== null) { rateInfo.textContent = remaining + " requests remaining in this window"; }
        urlInput.value = "";
        aliasInput.value = "";
        loadUrls();
      } catch (err) { errorDiv.textContent = "Something went wrong. Please try again."; errorDiv.style.display = "block"; }
    }
  • Finish by pasting the loadUrls function and event listeners:
    // Load the URL list using the authenticated API
    async function loadUrls() {
      try {
        const response = await fetch("/api/urls", { headers: getHeaders() });
        if (!response.ok) { urlList.innerHTML = "<li>Enter your API key above to see your URLs.</li>"; return; }
        const data = await response.json();
        urlList.innerHTML = data.urls.map((item) => `<li class="url-item"><a class="short" href="/${item.short_code}" target="_blank">${window.location.origin}/${item.short_code}</a><span class="original">${item.original_url}</span><span class="clicks">${item.clicks} click${item.clicks !== 1 ? "s" : ""}</span></li>`).join("");
      } catch (err) { urlList.innerHTML = "<li>Failed to load URLs.</li>"; }
    }

    // Wire up button click and Enter key, then load existing URLs
    shortenBtn.addEventListener("click", shortenUrl);
    urlInput.addEventListener("keypress", (e) => { if (e.key === "Enter") shortenUrl(); });
    loadUrls();

What changed in the JavaScript?

The getHeaders() function builds a headers object that includes the x-api-key header whenever a key is present in the input field. Every fetch call now uses getHeaders() instead of a hardcoded content-type header.

The script also saves the API key to localStorage so the learner does not have to re-paste it after every page refresh. On load, it checks localStorage and pre-fills the input if a key was saved previously.

  • Save index.html and refresh your browser.
  • Paste your API key into the API Key input field at the top of the page.
  • Enter a URL (like https://example.com) and click Shorten.

You should see a new short URL appear in the green result box, and your URL list should load below it.

Page not loading your URLs?

  • Make sure you pasted your API key into the input field before clicking Shorten. Without it, every request gets a 401.
  • Open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and look for error messages. A 401 means the key is not being sent correctly.
  • Confirm that the <script> and </script> tags are still present. You should have replaced only the content between them, not the tags themselves.
  • help me debug why my frontend is not connecting to the API

✔️ Awesome, I've got everything!

Great. Confirm you have saved both api/index.js and public/index.html.

ⓧ I'd like to double check the full code

Your public/index.html is too long to display in one block. Scroll up and confirm these pieces are present, in order:

  • The .api-key-group, .rate-info, and .error CSS rules inside <style>.
  • The api-key-group div and error div after <h1>.
  • The rate-info div inside the result div.
  • The getHeaders() function that includes x-api-key in the headers.
  • The shortenUrl() function using getHeaders() in its fetch call.
  • The loadUrls() function using getHeaders() in its fetch call.
  • The localStorage logic that saves and restores the API key.

Your API is now locked down. Every request to shorten or list URLs must include a valid API key. Next up, you will add rate limiting so a single key cannot flood your API with unlimited requests.

Add Rate Limiting

Your API now requires valid credentials for every request. Without a key, the server returns a 401.

But here is the catch: an authenticated user can still fire unlimited requests. There is nothing stopping someone from sending thousands of calls in a minute. Rate limiting puts a ceiling on how many requests each API key can make in a given time window.

In this step, get ready to:
  • Configure the rate limiter middleware with a 50-request-per-15-minute budget per API key.
  • Apply the rate limiter to your protected routes and trigger a 429 response.
Configure the rate limiter middleware

Before configuring the limiter itself, Express needs to know it is running behind a reverse proxy. Your Vercel deployment sits behind Vercel's edge network, so Express must trust the first proxy hop to read the real client IP.

  • In api/index.js, find this line near the top of the file:
const app = express();
  • Add this line directly below it:
app.set("trust proxy", 1);

Why trust proxy?

Without this setting, Express sees Vercel's proxy IP instead of the real client IP. That would cause every user to share a single rate limit bucket, making the per-key budget useless.

Now you can configure the rate limiter itself. The express-rate-limit package you installed earlier provides a middleware that tracks requests per key and blocks any that exceed the budget.

  • Add the rate limiter configuration below the closing brace of the validateApiKey function by pasting this code:
// Create a rate limiter for protected API routes
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15-minute window
  limit: 50,                  // max 50 requests per window
  standardHeaders: "draft-6", // send individual RateLimit-* headers
  legacyHeaders: false,       // disable old X-RateLimit-* headers
  // Identify users by their API key, fall back to IP
  keyGenerator: (req) => req.headers["x-api-key"] || req.ip,
  message: { error: "Too many requests, please try again later" },
});

What does this code do?

  • windowMs sets the time window to 15 minutes. After 15 minutes, the counter resets.
  • limit caps each user at 50 requests per window.
  • standardHeaders: "draft-6" sends individual RateLimit-Remaining, RateLimit-Limit, and RateLimit-Reset headers that clients can read.
  • keyGenerator uses the x-api-key header to identify each user, so every API key gets its own separate budget.
  • Save api/index.js.
  • Stop your running server with Ctrl+C, then restart it by running:
node api/index.js
  • You should see Server running on http://localhost:3000 with no errors.

Seeing a syntax error on startup?

  • Make sure the apiLimiter block is placed after the closing brace of validateApiKey and before initDatabase(). Placing it inside another function will cause errors.
  • Check that app.set("trust proxy", 1) is on its own line directly below const app = express(), not inside the rateLimit() call.
  • Still stuck? Help me debug a syntax error when starting my Express server after adding rate limiting.
Apply the rate limiter and test the limit

The apiLimiter is defined but not attached to any routes yet. You need to chain it after validateApiKey on the routes you want to protect.

  • In api/index.js, find the POST /api/shorten route. Look for this line:
app.post("/api/shorten", validateApiKey, async (req, res) => {
  • Add apiLimiter after validateApiKey, so the line now looks like this:
app.post("/api/shorten", validateApiKey, apiLimiter, async (req, res) => {
  • Now find the GET /api/urls route. Look for this line:
app.get("/api/urls", validateApiKey, async (req, res) => {
  • Add apiLimiter after validateApiKey here too, so the line now looks like this:
app.get("/api/urls", validateApiKey, apiLimiter, async (req, res) => {

Why does apiLimiter come after validateApiKey?

Express middleware runs in the order you list it. By placing validateApiKey first, the server rejects invalid keys with a 401 before the rate limiter even runs.

This means unauthenticated requests never consume anyone's rate limit budget. Only valid, authenticated requests count toward the limit.

  • Save api/index.js.
  • Stop the running server with Ctrl+C and restart it by running:
node api/index.js
  • Confirm you see Server running on http://localhost:3000.

Time to prove the rate limiter works. You are about to fire 55 requests in quick succession. Before you run the loop, take a guess: what do you think will happen after the 50th request?

  • Open a second terminal window and send a burst of 55 requests by running this loop:

macOS

for i in $(seq 1 55); do
  echo "Request $i: $(curl -s -o /dev/null \
    -w '%{http_code}' \
    -X POST http://localhost:3000/api/shorten \
    -H 'Content-Type: application/json' \
    -H 'x-api-key: your-api-key-here' \
    -d "{\"url\":\"https://example.com/$i\"}")"
done
  • Replace your-api-key-here with the API key you generated in the previous step.

Windows

for ($i = 1; $i -le 55; $i++) {
  $status = curl.exe -s -o NUL -w "%{http_code}" `
    -X POST http://localhost:3000/api/shorten `
    -H "Content-Type: application/json" `
    -H "x-api-key: your-api-key-here" `
    -d "{`"url`":`"https://example.com/$i`"}"
  Write-Host "Request $i : $status"
}
  • Replace your-api-key-here with the API key you generated in the previous step.

Watch the output scroll by. Requests 1 through 50 return 201 (created). Then starting at request 51, the status flips to 429 (too many requests).

What are those RateLimit headers?

Every response from your rate-limited routes now includes three headers that clients can read:

  • RateLimit-Limit shows the maximum requests allowed (50).
  • RateLimit-Remaining counts down how many requests are left in the current window.
  • RateLimit-Reset tells the client when the window resets so they can retry.

Your frontend already reads RateLimit-Remaining and displays it below the short URL result. Try shortening a URL in the browser to see the count.

All 55 requests returning 201?

  • Make sure you added apiLimiter to both the POST /api/shorten and GET /api/urls routes, not just defined the middleware.
  • Check that you restarted the server after saving your changes. The old code without the limiter is still running until you restart.
  • Verify that the API key in your loop matches the key you generated earlier. A mismatched key would return 401 instead of 201 or 429.
  • Need a hand? Help me troubleshoot why my rate limiter is not blocking requests after 50 calls.

Awesome, I've got everything!

Great. Double check that you have saved api/index.js.

I'd like to double check the full code

Scroll through api/index.js and confirm these four changes are in place, in order from top to bottom:

  • app.set("trust proxy", 1) on the line directly after const app = express().
  • The apiLimiter constant defined with rateLimit({...}) placed after the validateApiKey function and before initDatabase().
  • apiLimiter added between validateApiKey and async (req, res) on the POST /api/shorten route.
  • apiLimiter added between validateApiKey and async (req, res) on the GET /api/urls route.

Your API now has authentication and rate limiting. Every request requires a valid key, and each key is capped at 50 requests per 15 minutes. Next up, you will build a bulk shortening endpoint so developers can shorten multiple URLs in a single call.

Build Bulk Shortening and Redeploy

Your API now has authentication and rate limiting in place. Every request needs a valid key, and each key gets a 50-request budget per window.

There is one more feature developers expect from a production API: bulk operations. Right now, shortening 10 URLs means making 10 separate requests. In this step, you will add a single endpoint that accepts up to 10 URLs at once, then deploy the complete secured API to Vercel.

In this step, get ready to:
  • Add a bulk shorten endpoint that processes up to 10 URLs in one request.
  • Deploy the secured API to Vercel and test it live.
Add the POST /api/bulk-shorten endpoint

A bulk endpoint lets a developer send an array of URLs in a single authenticated request instead of looping through individual calls. The endpoint validates each URL independently, so one bad URL does not block the rest.

Before adding the new route, the read-only GET /api/urls route currently has apiLimiter applied. Rate limiting makes more sense on write endpoints that create data. Let's remove it from the listing route.

  • In api/index.js, scroll down to your GET /api/urls route.
  • Find this line:
app.get("/api/urls", validateApiKey, apiLimiter, async (req, res) => {
  • Remove apiLimiter, so the line now looks like this:
app.get("/api/urls", validateApiKey, async (req, res) => {
  • Now add the bulk shorten endpoint. Find the closing }); of your POST /api/shorten route and copy the following code below it:
// Bulk shorten: accepts an array of URLs in one request
app.post("/api/bulk-shorten", validateApiKey, apiLimiter, async (req, res) => {
  const { urls } = req.body;

  // Require a non-empty array of URLs
  if (!urls || !Array.isArray(urls) || urls.length === 0) {
    return res.status(400).json({ error: "An array of URLs is required" });
  }

  // Cap at 10 URLs per request
  if (urls.length > 10) {
    return res.status(400).json({ error: "Maximum 10 URLs per request" });
  }

  const results = [];
  for (const url of urls) {
    // Validate each URL individually, skip invalid ones
    try { new URL(url); } catch { results.push({ url, error: "Invalid URL format" }); continue; }

    const shortCode = nanoid(8);
    try {
      await db.execute({ sql: "INSERT INTO urls (original_url, short_code) VALUES (?, ?)", args: [url, shortCode] });
      results.push({ shortCode, shortUrl: `${req.protocol}://${req.get("host")}/${shortCode}`, originalUrl: url });
    } catch (error) { results.push({ url, error: "Failed to shorten URL" }); }
  }

  res.status(201).json({ results });
});

What does this code do?

This route is protected by both validateApiKey and apiLimiter, just like POST /api/shorten. The whole batch counts as one request against the rate limit.

  • The two guard clauses reject requests with no urls array or more than 10 items.
  • The for loop validates each URL individually. Invalid URLs get an error entry in the results array instead of crashing the entire request.
  • Valid URLs each get a short code via nanoid(8) and are inserted into the urls table, the same table your single-URL endpoint uses.

✔️ Awesome, I've got everything!

Great. Make sure you have saved api/index.js before continuing.

ⓧ I'd like to double check the full code

Scroll through your api/index.js and confirm these sections are present, in order:

  • The require imports at the top, including crypto and rateLimit.
  • The generateApiKey, hashApiKey, and validateApiKey functions.
  • The apiLimiter configuration and initDatabase with both the urls and api_keys tables.
  • The POST /api/keys route (no auth required).
  • The POST /api/shorten route with validateApiKey and apiLimiter.
  • Your new POST /api/bulk-shorten route with validateApiKey and apiLimiter.
  • The GET /api/urls route with only validateApiKey (no apiLimiter).
  • The GET /:shortCode redirect route (no auth).
  • Save api/index.js.
  • Restart your server by pressing Ctrl+C in the terminal, then run:
node api/index.js

You should see Server running on http://localhost:3000 with no errors.

Seeing a syntax error on startup?

  • Check that the closing }); of your POST /api/shorten route is still intact. The new route should start on the line after it.
  • Make sure you removed only apiLimiter, from the GET /api/urls line and did not accidentally delete validateApiKey or the comma after it.

Help me fix this startup error.

Test locally and deploy to Vercel

The server is running, but you have not proven the new endpoint actually works yet. Before you deploy anything to the live internet, test it locally first.

Before you run the next command, what do you expect to see? How many short codes should come back in the response?

  • Test the bulk endpoint by sending 3 URLs in one request. Run this command, replacing your-api-key-here with the API key you generated earlier:
curl -X POST http://localhost:3000/api/bulk-shorten \
  -H "Content-Type: application/json" \
  -H "x-api-key: your-api-key-here" \
  -d '{"urls": ["https://example.com", "https://github.com", "https://nodejs.org"]}'

You should see a JSON response with a results array containing 3 objects. Each object has a shortCode, shortUrl, and originalUrl.

Getting a 401 or empty response?

  • A 401 means your API key is missing or wrong. Double check you pasted the full key after x-api-key: with no extra spaces.
  • If you see an error about the request body, make sure the JSON uses the key "urls" (not "url") and the value is an array with square brackets.

Help me debug my bulk shorten request.

The endpoint works locally. Time to push everything to GitHub so Vercel can auto-deploy your changes.

  • Stop the local server with Ctrl+C.
  • Push your changes to GitHub by running these commands:
git add .
git commit -m "Add API key auth, rate limiting, and bulk shorten"
git push

What happens after you push?

Vercel watches your GitHub repository. When it detects a new commit, it automatically builds and deploys the updated code. This usually takes 30 to 60 seconds.

  • Wait about a minute for the deployment to finish.

Now test the live API. Before you run it, do you expect the same response you saw locally, or could something be different on Vercel?

  • Test your live bulk endpoint by running this command, replacing the URL and API key with your own:
curl -X POST https://[[VERCEL_URL="your-app-name.vercel.app"]]/api/bulk-shorten \
  -H "Content-Type: application/json" \
  -H "x-api-key: your-api-key-here" \
  -d '{"urls": ["https://example.com", "https://github.com", "https://nodejs.org"]}'

You should see the same results array with 3 short code objects, now with your live Vercel domain in each shortUrl.

Live request failing?

  • Open your Vercel dashboard and check that the latest deployment shows a green Ready status. If it shows an error, click the deployment to see the build logs.
  • Make sure your Vercel project still has the TURSO_DATABASE_URL and TURSO_AUTH_TOKEN environment variables set. Without them, the database connection fails silently.
  • If you get a 401, remember that the API key you generated is stored in your Turso database. The same key works both locally and on Vercel because they share the same database.

Help me fix my Vercel deployment.

Secret mission

Add Per-Key Usage Tracking

Your API keys are secured and rate-limited, but you have no visibility into who is using them or how often. Add per-key usage tracking that counts every authenticated request and lets you query usage stats through a new endpoint.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on free tiers, 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:

  • Turso database (free Starter plan, shared with Part 1).
  • Vercel deployment (free Hobby plan, shared with Part 1).
  • Local project folder (url-shortener on your Desktop).
  • GitHub repository for the URL shortener.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep your API live.

  • Both Turso and Vercel are on free tiers with no ongoing costs.
  • Your deployment stays live at your Vercel URL, and your Turso database continues to store URLs and API keys.
  • Your local url-shortener folder and GitHub repository remain untouched.

Pause - I'll come back to this later

Since both Turso and Vercel are on free tiers, there is nothing to pause. Your deployment stays live at no cost.

  • If you have a local development server running, stop it by pressing Ctrl+C in the terminal.
  • Everything else stays in place for when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow the steps below for each resource.

  • Delete the Vercel project: open the Vercel dashboard, select your URL shortener project, click Settings in the sidebar, scroll to the bottom of the General page, and click Delete in the Delete Project section. Enter the project name to confirm, then click Continue.
  • Delete the Turso database: if you have the Turso CLI installed, run the following command (replacing the database name with your own):
  • Delete your local project folder on macOS/Linux by running:
turso db destroy your-database-name

Don't have the Turso CLI?

You can also delete the database from the Turso dashboard at turso.tech. Select your database, then look for the delete option in its settings.

  • Delete your local project folder on macOS/Linux by running:
rm -rf ~/Desktop/url-shortener
  • On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\url-shortener
  • Delete the GitHub repository: open your repository on GitHub, click Settings, scroll down to the Danger Zone section, and click Delete this repository. Click I want to delete this repository, read the warnings, type the repository name to confirm, then click Delete this repository.

Nice Work!

Nice Work!

Nice work! You took a wide-open URL shortener and turned it into a properly secured API with authentication, rate limiting, and bulk operations.

You've learned how to:

  • Build an API key system that generates cryptographically secure keys with Node.js crypto, hashes them with SHA-256 before storage, and validates incoming requests by comparing hashes against your Turso database.
  • Protect your Express routes with chained middleware that enforces API key authentication and rate limiting of 50 requests per 15-minute window per key, returning proper 429 responses with standard rate limit headers.
  • Create a bulk shorten endpoint that accepts up to 10 URLs in a single authenticated request, and deploy the secured API to Vercel with a live URL you can share with developers.
  • Secret Mission: Added per-key usage tracking that counts requests and records last-used timestamps, queryable via a stats endpoint.

Ready to quiz yourself?