Harden a Next.js Notes API

Exploit and fix SQL injection, XSS, and IDOR in a Next.js notes app.

Introduction

30 Second Summary

Every app you use stores personal data. When a developer skips a security check, attackers can read your private information, delete your data, or run malicious code in your browser.

In this project, you will build a notes app with Next.js Server Actions and PostgreSQL that starts deliberately insecure. You will exploit three real vulnerabilities yourself, then fix each one using patterns from the official Next.js security documentation.

What You'll Build

Picture yourself typing a crafted search string into your own app's search bar, watching it dump every user's private notes, then applying the fix and proving the same attack now fails harmlessly.

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

  • A running notes app where you exploit SQL injection to dump all users' data, use IDOR to read another user's private notes, and fire XSS alerts in the browser.
  • The same app, hardened: you re-run every exploit and watch each one fail. Zod rejects malicious input, parameterized queries neutralize injection, and authorization checks return "Forbidden" for cross-user access.
  • Content Security Policy and security headers visible in your browser's DevTools that block injected scripts from executing, adding a final defense layer even if a new XSS vector appears later.
  • Secret Mission: Build a Data Access Layer that enforces authorization at the query level, making it impossible to forget an ownership check.

Are there any prerequisites?

This project assumes familiarity with the Next.js App Router, TypeScript, and basic SQL. You do not need prior security experience.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. This project puts you in the shoes of both attacker and defender, exploiting real vulnerabilities in your own code and then fixing them with production-grade patterns.

Set Up the Project

Before you can break into your own app and fix the holes, you need a working app to attack. This step sets up the foundation: a Next.js project with a PostgreSQL database behind it.

You will scaffold the project, spin up a database in Docker, and seed it with sample data for two users. By the end, you will have a running dev server and a populated database ready for the insecure code you will write in the next step.

In this step, get ready to:
  • Scaffold a Next.js project with TypeScript and start a PostgreSQL container.
  • Install the database client and validation library, then configure the connection.
  • Seed the database with a notes table and sample data for two users.
Scaffold the project and start PostgreSQL

You need a Next.js app to work with and a PostgreSQL database to store notes. The app will use Server Actions to interact with the database, and Docker makes spinning up PostgreSQL instant.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the Next.js project by running this command:
npx create-next-app@latest secure-notes --yes

What does this command do?

The --yes flag tells create-next-app to skip all interactive prompts and use the recommended defaults: TypeScript, Tailwind CSS, ESLint, and the App Router.

The project files land in a new secure-notes folder on your Desktop.

You should see the scaffolding output finish with a success message listing the installed packages.

  • Move into the new project folder by running:
cd secure-notes

Next, you will create a Docker Compose file that defines the PostgreSQL database container.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the secure-notes folder on your Desktop, then click Open.
  • Create a new file called docker-compose.yml in the root of your secure-notes folder by right-clicking in the Explorer sidebar and selecting New File.
  • Paste the following configuration into docker-compose.yml:
services:
  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_USER: notes_user
      POSTGRES_PASSWORD: notes_pass
      POSTGRES_DB: notes_db
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

What does this file do?

This defines a PostgreSQL 16 container named db that listens on port 5432. It creates a database called notes_db with credentials notes_user / notes_pass.

The pgdata volume persists your data between container restarts so you do not lose your notes when you stop Docker.

  • Save docker-compose.yml with Cmd+S (macOS) or Ctrl+S (Windows).
  • Start the PostgreSQL container by running this command in your terminal:
docker compose up -d
  • Confirm the container is running by running:
docker compose ps

You should see a single container with the name containing db and a status of running.

Container not starting?

  • Make sure Docker Desktop is running. If you see a connection error, open Docker Desktop and wait for it to finish starting.
  • If port 5432 is already in use, another PostgreSQL instance may be running. Stop it or change the port mapping in docker-compose.yml.
  • Still stuck? Why isn't my Docker container starting for this project?
Install dependencies and configure the database connection

Your app needs two packages: postgres (the database client that uses safe tagged template queries by default) and zod (the validation library you will use to sanitize inputs later).

  • Install both packages by running this command in your terminal:
npm install postgres@3.4.9 zod@4.4.3

You should see the packages added to your package.json dependencies in the terminal output.

Install failing?

  • Make sure you are inside the secure-notes folder. Run pwd to check your current directory.
  • If you see a permissions error, try prefixing the command with sudo (macOS/Linux) or run your terminal as Administrator (Windows).

Now you will create the environment variable file and the database connection module.

  • Create a new file called .env.local in the root of your secure-notes folder and paste this single line:
DATABASE_URL=postgres://notes_user:notes_pass@localhost:5432/notes_db

Why .env.local?

Next.js automatically loads .env.local into process.env at dev server startup. This keeps your connection string out of source code. The .gitignore that create-next-app generated already excludes it.

  • Create the folder src/lib by right-clicking the src folder in the Explorer sidebar and selecting New Folder. Name it lib.
  • Create a new file inside src/lib called db.ts and paste this code:
import postgres from "postgres";

// Create a tagged template SQL client using the connection string
const sql = postgres(process.env.DATABASE_URL!);

export default sql;

What does this code do?

This creates a sql tagged template function from the postgres library. When you write sql`SELECT * FROM notes WHERE id = ${noteId}`, the library automatically sends a parameterized query. The value never touches the SQL string directly.

This safe-by-default design is exactly why we chose this library. Later, you will see what happens when you bypass it with sql.unsafe().

  • Save db.ts.
  • Confirm your new files exist by running:
ls src/lib

You should see db.ts listed in the output.

Create the database schema and seed data

Your database is running but empty. You need a notes table and some sample data for two different users so you can test authorization flaws later.

  • Create a new file inside src/lib called seed.ts and paste this code:
import postgres from "postgres";

// Connect directly (not via the shared client) so we can close cleanly
const sql = postgres(process.env.DATABASE_URL!);

async function seed() {
  // Create the notes table if it doesn't exist
  await sql`
    CREATE TABLE IF NOT EXISTS notes (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      user_id TEXT NOT NULL,
      title TEXT NOT NULL,
      content TEXT NOT NULL,
      created_at TIMESTAMPTZ DEFAULT now()
    )
  `;

  // Insert sample notes for Alice (user 1) and Bob (user 2)
  await sql`
    INSERT INTO notes (user_id, title, content) VALUES
      ('1', 'Alice Meeting Notes', 'Discuss Q3 roadmap with the team'),
      ('1', 'Alice Todo', 'Buy groceries, call dentist'),
      ('2', 'Bob Secret Project', 'Confidential: new product launch details'),
      ('2', 'Bob Personal', 'Birthday gift ideas for mom')
    ON CONFLICT DO NOTHING
  `;

  console.log("Database seeded successfully");
  await sql.end();
}

seed().catch(console.error);

What does this seed script do?

It creates a notes table with columns for id, user_id, title, content, and created_at. Each note belongs to a user identified by user_id.

It then inserts four notes: two for Alice (user 1) and two for Bob (user 2). Having two separate users is critical for testing IDOR attacks later.

  • Save seed.ts.
  • Run the seed script by executing this command in your terminal:
npx tsx --env-file=.env.local src/lib/seed.ts

What does this command do?

The npx tsx command runs a TypeScript file directly without a separate compile step. The --env-file=.env.local flag loads your DATABASE_URL into process.env before the script starts.

You should see Database seeded successfully printed in your terminal.

  • Verify the data made it into the database by running:
docker exec -it secure-notes-db-1 psql -U notes_user -d notes_db -c "SELECT count(*) FROM notes;"

You should see a count of 4 returned, confirming all four sample notes are in the database.

Seeing 'Database seeded successfully' but count is 0?

  • The ON CONFLICT DO NOTHING clause means re-running the seed will not insert duplicates. If the table already existed with different primary keys, the inserts silently skip. Drop the table and re-run: docker exec -it secure-notes-db-1 psql -U notes_user -d notes_db -c "DROP TABLE notes;" then re-run the seed command.
  • If the container name does not match, run docker compose ps to find the actual container name and substitute it in the command.
  • Still stuck? Help me troubleshoot my database seed script.

Before you move on, confirm the dev server starts cleanly. What do you think you will see when you visit the app in the browser?

  • Start the development server by running:
npm run dev
  • Open your browser and visit http://localhost:3000.

You should see the default Next.js starter page with the Next.js logo and documentation links. This confirms your project is running correctly.

Page not loading?

  • Check your terminal for error messages. If you see a port conflict, another process is using port 3000. Stop it or kill the process.
  • Make sure you ran npm run dev from inside the secure-notes folder, not a parent directory.
  • Need help? My Next.js dev server is not starting.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here are the files you created in this step. Scroll up and confirm each one matches.

docker-compose.yml:

services:
  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_USER: notes_user
      POSTGRES_PASSWORD: notes_pass
      POSTGRES_DB: notes_db
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

.env.local:

DATABASE_URL=postgres://notes_user:notes_pass@localhost:5432/notes_db

src/lib/db.ts:

import postgres from "postgres";

const sql = postgres(process.env.DATABASE_URL!);

export default sql;

src/lib/seed.ts:

import postgres from "postgres";

const sql = postgres(process.env.DATABASE_URL!);

async function seed() {
  await sql`
    CREATE TABLE IF NOT EXISTS notes (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      user_id TEXT NOT NULL,
      title TEXT NOT NULL,
      content TEXT NOT NULL,
      created_at TIMESTAMPTZ DEFAULT now()
    )
  `;

  await sql`
    INSERT INTO notes (user_id, title, content) VALUES
      ('1', 'Alice Meeting Notes', 'Discuss Q3 roadmap with the team'),
      ('1', 'Alice Todo', 'Buy groceries, call dentist'),
      ('2', 'Bob Secret Project', 'Confidential: new product launch details'),
      ('2', 'Bob Personal', 'Birthday gift ideas for mom')
    ON CONFLICT DO NOTHING
  `;

  console.log("Database seeded successfully");
  await sql.end();
}

seed().catch(console.error);

Your environment is ready. You have a running Next.js app, a live PostgreSQL database with sample data, and the tools you need to build and break things. Next up, you will write the insecure version of the notes app so you have real vulnerabilities to exploit.

Build the Insecure Notes App

Your database is seeded and the dev server is running. Now you need a working notes app that you can actually attack.

In this step, you will build a notes app with Server Actions that contains three real-world vulnerabilities: SQL injection, IDOR (Insecure Direct Object Reference), and XSS (Cross-Site Scripting). These flaws mirror what developers actually ship when they skip security checks.

In this step, get ready to:
  • Create a simulated auth system to switch between two users.
  • Build Server Actions with no input validation, no authorization, and an unsafe query.
  • Wire up a page that renders note content using a dangerous HTML sink.
Add a simple auth simulation

Before building the Server Actions, you need a way to identify which user is making a request. A full auth library would consume most of the project time, so you will use a simple cookie-based simulation with two hardcoded users.

  • Create a new file at src/lib/auth.ts and add the following code:
import { cookies } from "next/headers";

export interface User {
  id: string;
  name: string;
}

// Two hardcoded users for testing authorization
const USERS: Record<string, User> = {
  "1": { id: "1", name: "Alice" },
  "2": { id: "2", name: "Bob" },
};

export async function getCurrentUser(): Promise<User> {
  const cookieStore = await cookies();
  const userId = cookieStore.get("user_id")?.value ?? "1";
  return USERS[userId] ?? USERS["1"];
}

What does this code do?

The getCurrentUser() function reads a user_id cookie to determine who is logged in. If no cookie exists, it defaults to user "1" (Alice).

This gives you a way to toggle between Alice and Bob so you can test authorization flaws later. The pattern transfers to any real auth system.

  • Save src/lib/auth.ts.
  • Confirm the file exists by running:
ls src/lib/

You should see auth.ts, db.ts, and seed.ts listed.

Create Server Actions with deliberate vulnerabilities

Server Actions in Next.js are server-side functions exposed as POST endpoints. Any exported function in a "use server" file is reachable via a direct POST request. That means skipping validation or authorization checks leaves those endpoints wide open.

  • Create a new file at src/app/actions.ts and add the imports and first two actions:
"use server";

import sql from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { revalidatePath } from "next/cache";

export async function getNotes(formData?: FormData) {
  const user = await getCurrentUser();
  const search = formData?.get("search")?.toString() ?? "";

  // VULNERABILITY: sql.unsafe() interpolates user input directly
  const notes = search
    ? await sql.unsafe(`SELECT id, title, content, created_at FROM notes WHERE user_id = '${user.id}' AND title LIKE '%${search}%' ORDER BY created_at DESC`)
    : await sql`SELECT id, title, content, created_at FROM notes WHERE user_id = ${user.id} ORDER BY created_at DESC`;

  return { error: null, notes };
}

export async function getNote(noteId: string) {
  // VULNERABILITY: no ownership check - any user can read any note
  const [note] = await sql`
    SELECT id, user_id, title, content, created_at FROM notes
    WHERE id = ${noteId}
  `;

  if (!note) {
    return { error: "Note not found", note: null };
  }

  return { error: null, note };
}

What vulnerabilities are in this code?

The getNotes function uses sql.unsafe() when a search term is provided. This directly interpolates the user's input into the SQL string, creating a SQL injection point.

The getNote function fetches any note by ID without checking whether the current user owns it. This is an IDOR vulnerability.

  • Below the getNote function, add the remaining three actions:
export async function createNote(formData: FormData) {
  const user = await getCurrentUser();
  // VULNERABILITY: no input validation at all
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  await sql`
    INSERT INTO notes (user_id, title, content)
    VALUES (${user.id}, ${title}, ${content})
  `;

  revalidatePath("/");
  return { error: null };
}

export async function deleteNote(noteId: string) {
  // VULNERABILITY: no ownership check - any user can delete any note
  await sql`DELETE FROM notes WHERE id = ${noteId}`;
  revalidatePath("/");
  return { error: null };
}

export async function switchUser(userId: string) {
  const { cookies } = await import("next/headers");
  const cookieStore = await cookies();
  cookieStore.set("user_id", userId);
  revalidatePath("/");
}

What does this code do?

The createNote action accepts raw form data with no validation. There is no length check, no type check, and no sanitization.

The deleteNote action deletes any note by ID without checking ownership. A user can delete another user's notes.

The switchUser action sets the user_id cookie to simulate logging in as a different user.

  • Save src/app/actions.ts.
  • Confirm the file was created by running:
ls src/app/

You should see actions.ts listed alongside the existing app files.

Seeing a TypeScript error in your editor?

If your editor underlines the @/lib/auth import, make sure you saved src/lib/auth.ts from the previous substep. The import resolves to that file.

help me fix a TypeScript import error in my Server Actions file

Build the UI with a deliberate XSS sink

The final piece is the page component. It calls your Server Actions and renders note content in a way that allows injected scripts to execute in the browser.

  • Open src/app/page.tsx and replace its entire contents with the following imports, data fetching, and header section:
import { getNotes, createNote, deleteNote, switchUser } from "./actions";
import { getCurrentUser } from "@/lib/auth";

export default async function Home() {
  const user = await getCurrentUser();
  const { notes, error } = await getNotes();

  return (
    <main className="max-w-2xl mx-auto p-6">
      <header className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-bold">Secure Notes</h1>
        <div className="flex gap-2 items-center">
          <span className="text-sm text-gray-600">Logged in as: {user.name}</span>
          <form action={switchUser.bind(null, user.id === "1" ? "2" : "1")}>
            <button type="submit" className="text-sm bg-gray-200 px-3 py-1 rounded hover:bg-gray-300">
              Switch to {user.id === "1" ? "Bob" : "Alice"}
            </button>
          </form>
        </div>
      </header>

What does this code do?

This is a Server Component that fetches notes on the server before the page renders. The header displays the current user's name and a button to switch between Alice and Bob using switchUser.bind().

  • Directly below the closing </header> tag (still inside the <main>), add the form and notes list to complete the file:
      <section className="mb-8">
        <h2 className="text-lg font-semibold mb-3">Create Note</h2>
        <form action={createNote} className="space-y-3">
          <input name="title" placeholder="Title" required className="w-full border rounded px-3 py-2" />
          <textarea name="content" placeholder="Content" required className="w-full border rounded px-3 py-2 h-24" />
          <button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">Add Note</button>
        </form>
      </section>

      <section>
        <h2 className="text-lg font-semibold mb-3">Your Notes</h2>
        {error && <p className="text-red-600 mb-3">{error}</p>}
        {notes.length === 0 ? (
          <p className="text-gray-500">No notes yet.</p>
        ) : (
          <ul className="space-y-3">
            {notes.map((note: any) => (
              <li key={note.id} className="border rounded p-4">
                <div className="flex justify-between items-start">
                  <div>
                    <h3 className="font-medium">{note.title}</h3>
                    <p className="text-gray-700 mt-1" dangerouslySetInnerHTML={{ __html: note.content }} />
                  </div>
                  <form action={deleteNote.bind(null, note.id)}>
                    <button type="submit" className="text-red-600 text-sm hover:underline">Delete</button>
                  </form>
                </div>
              </li>
            ))}
          </ul>
        )}
      </section>
    </main>
  );
}

Where is the XSS vulnerability?

The line dangerouslySetInnerHTML={{ __html: note.content }} tells React to render the note's content as raw HTML instead of escaping it. If a note contains a <script> tag or an event handler like onerror, it executes in the browser.

React normally escapes all output by default. Using dangerouslySetInnerHTML bypasses that protection entirely.

  • Save src/app/page.tsx.

Before you check the browser, what do you expect to see? The dev server should hot-reload with your new page showing notes for Alice.

  • Switch to your browser and visit http://localhost:3000.

You should see the "Secure Notes" heading, a "Create Note" form, and Alice's two seeded notes ("Alice Meeting Notes" and "Alice Todo") listed below with delete buttons.

  • Test the create form by entering a title and content, then clicking Add Note.
  • Confirm the new note appears in the list.
  • Click Switch to Bob and confirm the notes list changes to show Bob's notes.

Page not loading or showing an error?

Check that your dev server is still running in the terminal. If it crashed, run npm run dev again.

If you see a module-not-found error, verify the file paths match exactly: src/lib/auth.ts and src/app/actions.ts.

help me debug why my Next.js notes page is not loading

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files.

ⓧ I'd like to double check the full code

Scroll up and confirm you have these sections in each file, in order:

  • src/lib/auth.ts: the User interface, USERS record, and getCurrentUser() function.
  • src/app/actions.ts: the "use server" directive at the top, imports, then getNotes, getNote, createNote, deleteNote, and switchUser functions.
  • src/app/page.tsx: imports, the Home component with header, create form, and notes list using dangerouslySetInnerHTML.

Your insecure notes app is running. You have a working app with three real vulnerabilities baked in. Next up, you will exploit each one yourself to see exactly how an attacker would abuse them.

Exploit Every Vulnerability

You have a running notes app. It looks fine on the surface. But in the previous step, you built in three deliberate security holes.

Now you are going to play the attacker. You will exploit each vulnerability yourself so you understand exactly how real attackers think. This discomfort is the point. You cannot defend what you do not understand.

In this step, get ready to:
  • Trigger SQL injection to dump all notes from all users.
  • Exploit IDOR to read and delete another user's private data.
  • Fire a stored XSS payload that executes JavaScript in the browser.
SQL injection via the search feature

The getNotes Server Action accepts a search parameter and feeds it directly into sql.unsafe(). But the current UI has no search form, so the vulnerable code path never triggers.

To exploit it, you need to wire up URL search parameters to that action. This simulates what happens when any frontend sends user input to the server.

  • In src/app/page.tsx, find the function signature:
export default async function Home() {
  • Replace it with this version that reads URL search parameters:
export default async function Home({
  searchParams,
}: {
  searchParams: Promise<{ search?: string }>;
}) {
  const params = await searchParams;
  const user = await getCurrentUser();

  // Build FormData from URL search param
  let formData: FormData | undefined;
  if (params.search) {
    formData = new FormData();
    formData.set("search", params.search);
  }
  const { notes, error } = await getNotes(formData);

What does this code do?

The searchParams prop captures anything after ? in the URL. You wrap the search value in a FormData object and pass it to getNotes, triggering the sql.unsafe() code path that was previously unreachable.

  • Remove the old lines that called getCurrentUser() and getNotes() below the original function signature (the new code above replaces them).
  • Save src/app/page.tsx.

Before you test this, think about what the SQL query becomes when you inject ' OR '1'='1 as the search value. Do you think it will return only Alice's notes, or something else?

  • Make sure your dev server is running. Open your browser and navigate to this URL:
http://localhost:3000?search=%27%20OR%20%271%27%3D%271

You should see ALL notes from BOTH users (Alice and Bob) displayed on the page. Notes like "Bob Secret Project" and "Bob Personal" now appear even though you are logged in as Alice.

How did the injection work?

The sql.unsafe() call in actions.ts builds a raw SQL string by concatenating your input directly:

SELECT id, title, content, created_at FROM notes
WHERE user_id = '1' AND title LIKE '%' OR '1'='1%'
ORDER BY created_at DESC

The OR '1'='1' condition is always true, so the database ignores the user_id filter and returns every row. The attacker typed a string into a search box and gained access to the entire database table.

IDOR: read and delete another user's notes

An Insecure Direct Object Reference happens when your app fetches a resource by its ID without checking whether the current user is allowed to access it.

The getNote and deleteNote actions in actions.ts accept a noteId but never verify that the note belongs to the requesting user. You are going to exploit that.

  • First, grab one of Bob's note IDs from the database by running:
docker exec -it secure-notes-db-1 psql -U notes_user -d notes_db -c "SELECT id, user_id, title FROM notes WHERE user_id = '2' LIMIT 1;"

You should see a row with a UUID in the id column and user_id of 2. Copy that UUID.

  • Now temporarily modify src/app/page.tsx to prove the IDOR vulnerability. Find the closing </main> tag and add this section just above it:
      {/* IDOR Exploit Test */}
      <section className="mt-8 border-t pt-4">
        <h2 className="text-lg font-semibold mb-3 text-red-600">
          IDOR Test (logged in as {user.name})
        </h2>
        <IDORTest />
      </section>
  • Add an IDORTest async component above the Home function. Replace the UUID below with the one you copied:
// Paste Bob's note UUID here
const BOB_NOTE_ID = "PASTE-BOB-UUID-HERE";

async function IDORTest() {
  const result = await getNote(BOB_NOTE_ID);
  return (
    <div className="bg-red-50 p-4 rounded">
      {result.error ? (
        <p className="text-red-600">{result.error}</p>
      ) : (
        <div>
          <p className="font-medium">
            Accessing note owned by user: {result.note?.user_id}
          </p>
          <p>{result.note?.title}</p>
          <p className="text-gray-700">{result.note?.content}</p>
        </div>
      )}
    </div>
  );
}

What does this prove?

You are logged in as Alice (user 1), but getNote happily returns Bob's (user 2) private note. The action never checks note.user_id !== currentUser.id. Any user who guesses or enumerates a valid UUID can read any note in the system.

  • Save src/app/page.tsx and refresh your browser.

You should see a red "IDOR Test" section at the bottom of the page showing Bob's note title and content, even though you are logged in as Alice.

Now prove the delete vulnerability works too. Do you think deleteNote will stop Alice from deleting Bob's note?

  • Open the browser console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and run:
// Submit a form that invokes the deleteNote Server Action
const form = document.createElement('form');
form.method = 'POST';
form.innerHTML = `<input type="hidden" name="noteId" value="PASTE-BOB-UUID-HERE">`;
document.body.appendChild(form);
form.requestSubmit();

Simpler delete test

Server Actions in Next.js use a special protocol that makes them hard to call from the browser console. If the form trick above does not work, verify the delete directly in the database instead:

  • Run this command using the same UUID you copied earlier:
docker exec -it secure-notes-db-1 psql -U notes_user -d notes_db -c "DELETE FROM notes WHERE id = 'PASTE-BOB-UUID-HERE';"
  • Confirm the note is gone by running:
docker exec -it secure-notes-db-1 psql -U notes_user -d notes_db -c "SELECT id, title FROM notes WHERE user_id = '2';"

You should see fewer rows for user 2. Alice just deleted Bob's private data without any authorization check stopping her. The deleteNote action ran the SQL with no ownership verification.

Not seeing Bob's note in the IDOR test?

Make sure you pasted a valid UUID from the database query. If the notes table is empty, re-run the seed script:

npx tsx src/lib/seed.ts

Then re-query Bob's note IDs and update the UUID in page.tsx.

Still stuck? Ask for help.

Stored XSS via note content

Cross-Site Scripting (XSS) happens when an application renders user-supplied content as executable HTML or JavaScript. Your page.tsx uses dangerouslySetInnerHTML to render note content, which means any HTML a user saves will execute in every visitor's browser.

  • Go back to http://localhost:3000 in your browser.
  • In the Title field, type XSS Test.
  • In the Content field, paste this payload:
<img src=x onerror="alert('XSS')">
  • Click Add Note.

What do you expect to happen when the page re-renders? Will you see the text <img src=x onerror="alert('XSS')"> displayed, or something else?

A JavaScript alert dialog fires in your browser with the text "XSS". The <img> tag's onerror handler executed because dangerouslySetInnerHTML injected the content as live HTML into the DOM.

Why is this dangerous in a real app?

A stored XSS payload persists in the database. Every user who views that note has the malicious script execute in their browser. An attacker could steal session cookies, redirect users to phishing pages, or make API calls on behalf of the victim.

The dangerouslySetInnerHTML prop exists for cases where you trust the content (like a CMS with admin-only editors). User-generated content should never pass through it.

Revert your temporary changes

You proved all three vulnerabilities work. Now restore page.tsx to its original state before you move on to fixing the code properly.

  • In src/app/page.tsx, revert the function signature back to:
export default async function Home() {
  const user = await getCurrentUser();
  const { notes, error } = await getNotes();
  • Remove the BOB_NOTE_ID constant, the IDORTest component, and the IDOR test <section> block from the JSX.
  • Remove the params and formData variables.
  • Save src/app/page.tsx.
  • Re-seed the database to restore any deleted notes by running:
npx tsx src/lib/seed.ts

Refresh http://localhost:3000. The page should look the same as it did at the start of this step, with Alice's notes visible and no IDOR test section.

Seeing a TypeScript error after reverting?

Make sure you removed all references to params, formData, searchParams, BOB_NOTE_ID, and IDORTest. The file should match what you had before this step.

Help me fix my page.tsx revert.

✔️ Awesome, I've got everything!

Great. Make sure you saved src/app/page.tsx in its reverted state.

ⓧ I'd like to double check the full code

Your src/app/page.tsx should be back to exactly this:

import { getNotes, createNote, deleteNote, switchUser } from "./actions";
import { getCurrentUser } from "@/lib/auth";

export default async function Home() {
  const user = await getCurrentUser();
  const { notes, error } = await getNotes();

  return (
    <main className="max-w-2xl mx-auto p-6">
      <header className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-bold">Secure Notes</h1>
        <div className="flex gap-2 items-center">

Scroll up to the full page.tsx you built in Step 2. Your file should match that original version exactly, with no leftover test code.

You have successfully executed all three attacks. SQL injection returned all notes regardless of user. IDOR let you read and delete another user's note. XSS fired JavaScript in your browser. Now you know exactly how each one works, which makes you ready to fix them properly in the next step.

Next up, you will apply the official Next.js security patterns to shut down every one of these attack vectors.

Fix Every Vulnerability

In the last step, you played the attacker. You broke into your own app three different ways and saw real damage: leaked data, unauthorized access, and code execution in the browser.

Now you play the defender. You will apply three layers of protection, one at a time, using the exact patterns from the official Next.js security documentation: Zod input validation, parameterized queries, and per-action authorization checks.

In this step, get ready to:
  • Add Zod schemas that reject malicious input before it reaches the database.
  • Replace the unsafe SQL query with a parameterized tagged template.
  • Add ownership checks and fix the XSS rendering sink.
Add Zod input validation to every Server Action

The first line of defense is rejecting bad input before it ever touches the database. If the SQL injection payload never reaches the query, it cannot do damage.

  • Create a new file src/lib/schemas.ts and add the following validation schemas:
import * as z from "zod";

export const CreateNoteSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1).max(5000),
});

export const NoteIdSchema = z.object({
  noteId: z.string().uuid(),
});

export const SearchSchema = z.object({
  search: z.string().max(100),
});

What does this code do?

Each schema defines the shape and constraints that input must match before your Server Action processes it.

  • CreateNoteSchema ensures the title is 1-200 characters and content is 1-5000 characters. Anything outside that range is rejected.
  • NoteIdSchema requires the note ID to be a valid UUID. The SQL injection payload ' OR '1'='1 is not a UUID, so it would be rejected instantly.
  • SearchSchema caps search input at 100 characters. Combined with the string type check, this blocks injection payloads.
  • Save src/lib/schemas.ts.

Now you need to wire these schemas into your Server Actions. You will replace the entire src/app/actions.ts file with a hardened version, starting with the imports and the getNotes function.

  • In src/app/actions.ts, replace the entire file contents with the following. Start with the imports and getNotes:
"use server";

import sql from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { CreateNoteSchema, NoteIdSchema, SearchSchema } from "@/lib/schemas";
import { revalidatePath } from "next/cache";

export async function getNotes(formData?: FormData) {
  const user = await getCurrentUser();
  const searchRaw = formData?.get("search")?.toString() ?? "";

  const parsed = SearchSchema.safeParse({ search: searchRaw });
  if (!parsed.success) {
    return { error: "Invalid search input", notes: [] };
  }

  const search = parsed.data.search;

  const notes = search
    ? await sql`
        SELECT id, title, content, created_at FROM notes
        WHERE user_id = ${user.id}
        AND title LIKE ${"%%" + search + "%%"}
        ORDER BY created_at DESC
      `
    : await sql`
        SELECT id, title, content, created_at FROM notes
        WHERE user_id = ${user.id}
        ORDER BY created_at DESC
      `;

  return { error: null, notes };
}

What does this code do?

  • SearchSchema.safeParse() validates the search input. If it fails (wrong type, too long, or contains anything unexpected), the function returns an error immediately.
  • The raw input is renamed to searchRaw and only the validated value (parsed.data.search) is used in the query.
  • The query now uses a tagged template (sql`...`) instead of sql.unsafe(). The tagged template automatically sends values as parameterized prepared statements, making SQL injection structurally impossible.
  • Below the getNotes function, add the hardened getNote function:
export async function getNote(noteId: string) {
  const user = await getCurrentUser();

  const parsed = NoteIdSchema.safeParse({ noteId });
  if (!parsed.success) {
    return { error: "Invalid note ID", note: null };
  }

  const [note] = await sql`
    SELECT id, user_id, title, content, created_at FROM notes
    WHERE id = ${parsed.data.noteId}
  `;

  if (!note) {
    return { error: "Note not found", note: null };
  }

  if (note.user_id !== user.id) {
    return { error: "Forbidden", note: null };
  }

  return { error: null, note };
}

What's happening here?

  • NoteIdSchema.safeParse() requires the note ID to be a valid UUID. Any junk value is rejected before the query runs.
  • getCurrentUser() fetches the logged-in user so the action can verify ownership.
  • The ownership check (note.user_id !== user.id) blocks the IDOR exploit. User A can no longer read User B's notes.
  • Below getNote, add the hardened createNote function:
export async function createNote(formData: FormData) {
  const user = await getCurrentUser();

  const parsed = CreateNoteSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });

  if (!parsed.success) {
    return { error: "Invalid input. Title and content are required." };
  }

  await sql`
    INSERT INTO notes (user_id, title, content)
    VALUES (${user.id}, ${parsed.data.title}, ${parsed.data.content})
  `;

  revalidatePath("/");
  return { error: null };
}

What does this code do?

CreateNoteSchema.safeParse() validates that title and content are present and within length limits. Only validated data from parsed.data is inserted into the database. The raw form values are never used directly.

  • Below createNote, add the hardened deleteNote and the existing switchUser function:
export async function deleteNote(noteId: string) {
  const user = await getCurrentUser();

  const parsed = NoteIdSchema.safeParse({ noteId });
  if (!parsed.success) {
    return { error: "Invalid note ID" };
  }

  const [note] = await sql`
    SELECT user_id FROM notes WHERE id = ${parsed.data.noteId}
  `;

  if (!note) {
    return { error: "Note not found" };
  }

  if (note.user_id !== user.id) {
    return { error: "Forbidden" };
  }

  await sql`DELETE FROM notes WHERE id = ${parsed.data.noteId}`;

  revalidatePath("/");
  return { error: null };
}

What does this code do?

  • deleteNote now fetches the note FIRST, checks that the current user owns it, and only then deletes it. Without this, any user could delete any note by guessing the ID.
  • The query uses parsed.data.noteId (the validated UUID) instead of the raw input.
  • Finally, add the switchUser function at the bottom of the file:
export async function switchUser(userId: string) {
  const { cookies } = await import("next/headers");
  const cookieStore = await cookies();
  cookieStore.set("user_id", userId);
  revalidatePath("/");
}
  • Save src/app/actions.ts.

Before you test, do you think the SQL injection payload from Step 3 will still work? What do you expect to see?

  • Make sure your dev server is running (npm run dev) and visit http://localhost:3000.
  • Try typing the SQL injection payload ' OR '1'='1 into the search input.

Zod rejects the payload before it ever reaches the database. You see no notes returned because the validation fails silently (the search string is technically valid but the query is now parameterized, so it searches for the literal string).

Seeing a compilation error?

  • Make sure you imported CreateNoteSchema, NoteIdSchema, and SearchSchema from @/lib/schemas at the top of actions.ts.
  • Confirm schemas.ts is saved in src/lib/ (not src/app/).
  • Check that you used import * as z from "zod" (not a default import).

Help me debug a compilation error in my actions.ts after adding Zod schemas.

Why did SQL injection stop working?

Two layers blocked it. First, SearchSchema validates the input is a plain string under 100 characters. The payload passes this check (it IS a short string).

The real defense is the tagged template query. When you write sql`...${value}...`, the postgres library sends the value as a separate parameter to PostgreSQL. The database treats it as a literal string value, never as SQL syntax. The injection payload becomes a search for a note titled ' OR '1'='1 (which does not exist).

Fix the XSS rendering sink

Your Server Actions are hardened, but the browser still has a vulnerability. The dangerouslySetInnerHTML attribute in page.tsx renders note content as raw HTML. Even with validated input, this is dangerous.

  • In src/app/page.tsx, find this line in the notes list:
<p className="text-gray-700 mt-1" dangerouslySetInnerHTML={{ __html: note.content }} />
  • Replace it with safe text rendering:
<p className="text-gray-700 mt-1">{note.content}</p>

Why is this safe?

React automatically escapes any string rendered with {} curly braces. The <img src=x onerror="alert('XSS')"> payload will display as visible text characters instead of being interpreted as HTML.

  • While you are in page.tsx, also update the component signature at the top of the file. Find:
export default async function Home() {
  • Replace it with:
export default async function Home({
  searchParams,
}: {
  searchParams: Promise<{ search?: string }>;
}) {
  const params = await searchParams;

Why add searchParams?

Next.js 16 requires Server Components to explicitly declare searchParams as a prop if the page might receive query parameters. This prepares the page for search functionality while following the framework's conventions.

  • Save src/app/page.tsx.
Verify all three exploits are blocked

Before you run these tests, predict: which of your three original attacks do you think still works? Or are all three blocked?

  • Refresh http://localhost:3000 in your browser.
  • Test SQL injection: type ' OR '1'='1 in the search input. You should see no results from other users. The payload is treated as a literal search string.
  • Test IDOR: while logged in as Alice, switch to Bob and note one of Bob's note IDs from the URL or network tab. Switch back to Alice and try to access Bob's note. You should see a "Forbidden" error returned.
  • Test XSS: look at the notes list. Any note that previously contained <img src=x onerror="alert('XSS')"> now displays that text as plain characters. No alert fires.

All three exploits are now blocked. SQL injection is stopped by both Zod validation and parameterized queries. IDOR is stopped by ownership checks. XSS is stopped by React's default text escaping.

XSS alert still firing?

  • Make sure you removed the entire dangerouslySetInnerHTML={{ __html: note.content }} attribute, not just part of it.
  • Confirm the replacement is {note.content} inside the <p> tag, not another dangerouslySetInnerHTML call.
  • Clear your browser cache or do a hard refresh (Cmd+Shift+R on macOS, Ctrl+Shift+R on Windows).

Help me figure out why my XSS payload is still executing after I changed page.tsx.

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/lib/schemas.ts, src/app/actions.ts, and src/app/page.tsx.

ⓧ I'd like to double check the full code

Your src/lib/schemas.ts should contain all three schemas:

import * as z from "zod";

export const CreateNoteSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1).max(5000),
});

export const NoteIdSchema = z.object({
  noteId: z.string().uuid(),
});

export const SearchSchema = z.object({
  search: z.string().max(100),
});

Your src/app/actions.ts should contain these functions in order. Scroll up and confirm each one matches:

  • Imports at the top: sql, getCurrentUser, all three schemas, revalidatePath.
  • getNotes with SearchSchema.safeParse and a tagged template query.
  • getNote with NoteIdSchema.safeParse and an ownership check.
  • createNote with CreateNoteSchema.safeParse.
  • deleteNote with NoteIdSchema.safeParse, a note fetch, and an ownership check.
  • switchUser unchanged at the bottom.

Your src/app/page.tsx should have:

  • The component signature accepting searchParams: Promise<{ search?: string }>.
  • const params = await searchParams; near the top of the function body.
  • Note content rendered as {note.content} (no dangerouslySetInnerHTML).

Your app now has defense-in-depth: validated inputs stop bad data at the door, parameterized queries make injection structurally impossible, and ownership checks prevent unauthorized access. Next up, you will add security headers to protect the browser layer too.

Add Security Headers

Your data layer is now hardened with validated inputs, parameterized queries, and ownership checks. But the browser itself is still unprotected.

Security headers instruct the browser how to behave when loading your pages. A Content Security Policy prevents injected scripts from executing even if a new XSS vector is discovered later. In this step, you add defense-in-depth at the browser level.

In this step, get ready to:
  • Configure security headers in next.config.ts that apply to every route.
  • Verify the headers appear in DevTools and confirm CSP is active.
Configure security headers in next.config.ts

Next.js lets you attach custom HTTP headers to every response through a headers() function in your config file. You will replace the default next.config.ts with a version that sends six security headers on every page.

  • Open next.config.ts in the root of your secure-notes folder.
  • Replace the entire file contents with the following code:
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          {
            key: "Content-Security-Policy",
            value: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'",
          },
          {
            key: "X-Frame-Options",
            value: "DENY",
          },
          {
            key: "X-Content-Type-Options",
            value: "nosniff",
          },
        ],
      },
    ];
  },
};

export default nextConfig;

What does this code do?

  • source: "/(.*)" matches every route, so these headers apply globally.
  • Content-Security-Policy tells the browser to only load scripts, styles, images, and fonts from your own domain ('self'). This blocks any injected script from a foreign source.
  • X-Frame-Options: DENY prevents other sites from embedding your app in an iframe, blocking clickjacking attacks.
  • X-Content-Type-Options: nosniff stops browsers from guessing file types, which prevents MIME-type confusion attacks.

Now add three more headers for transport security and privacy.

  • In next.config.ts, find the closing X-Content-Type-Options object (the line with value: "nosniff").
  • Add the following three header objects immediately after that closing }, (still inside the headers array):
          {
            key: "Strict-Transport-Security",
            value: "max-age=63072000; includeSubDomains",
          },
          {
            key: "Referrer-Policy",
            value: "strict-origin-when-cross-origin",
          },
          {
            key: "Permissions-Policy",
            value: "camera=(), microphone=(), geolocation=()",
          },

What do these headers do?

  • Strict-Transport-Security forces browsers to use HTTPS for two years (max-age=63072000 seconds), preventing downgrade attacks.
  • Referrer-Policy limits how much URL information is leaked when users click links to external sites.
  • Permissions-Policy disables camera, microphone, and geolocation APIs that your notes app does not need.
  • Save next.config.ts.

Seeing a syntax error after saving?

  • Make sure the three new header objects are inside the headers: [...] array, not outside the square brackets.
  • Check that each object has a trailing comma after its closing }.
  • Confirm the overall structure still ends with export default nextConfig; on its own line.

Help me fix a syntax error in my next.config.ts security headers

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

Your next.config.ts should have two chunks in order:

  • The config structure with Content-Security-Policy, X-Frame-Options, and X-Content-Type-Options (from the first code block above).
  • Strict-Transport-Security, Referrer-Policy, and Permissions-Policy (from the second code block above), placed inside the same headers array.

Scroll up and confirm both chunks are present in the correct positions. The file should have exactly six header objects inside a single headers array.

Verify headers and test CSP protection

Changes to next.config.ts require a dev server restart to take effect.

  • In the terminal where your dev server is running, press Ctrl+C to stop it.
  • Restart the dev server by running:
npm run dev
  • Open http://localhost:3000 in your browser.

Before you check DevTools, what headers do you expect to see on the response?

  • Open DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the Network tab.
  • Refresh the page with Cmd+R (macOS) or Ctrl+R (Windows).
  • Click the first request in the list (the document request for localhost).
  • Click the Headers sub-tab and scroll down to Response Headers.

You should see all six security headers listed in the response: Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Strict-Transport-Security, Referrer-Policy, and Permissions-Policy.

Why do I see CSP violations in the console?

In development mode, Next.js injects inline scripts for hot reload. Your strict script-src 'self' policy blocks those inline scripts, which shows up as violation warnings in the Console tab.

This is expected and harmless during development. In production (next build && next start), no inline scripts are injected and the CSP works cleanly. The violations actually prove your CSP is actively enforcing.

Not seeing the security headers?

  • Confirm you restarted the dev server after saving next.config.ts. Config changes do not hot-reload.
  • Make sure you are inspecting the document request (the first item in the Network list), not a JavaScript or CSS file.
  • Check that source: "/(.*)" is spelled exactly as shown, including the parentheses and dot-star.

Help me troubleshoot missing security headers in my Next.js dev server

Your notes app now has defense-in-depth: validated inputs stop bad data at the door, parameterized queries make injection structurally impossible, ownership checks enforce authorization, and security headers tell the browser to reject anything suspicious. Next up, take on the Secret Mission and restructure your app so that authorization can never be forgotten.

Secret mission

Build a Data Access Layer

Your Server Actions are secure, but they depend on every developer remembering to add authorization checks. What if you could make unauthorized access structurally impossible, so no one can ever forget?

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • Docker PostgreSQL container (secure-notes-db-1) running on port 5432.
  • PostgreSQL data volume (pgdata).
  • Local project folder (secure-notes/) on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the security patterns.

  • Your Docker PostgreSQL container stays running in the background.
  • Your project files remain in secure-notes/ on your Desktop, ready to pick up where you left off.
  • No ongoing costs are incurred since everything runs locally.

Pause - I'll come back to this later

Shut down the running container to free up memory. Your data persists in the Docker volume, so you can resume later without re-seeding.

  • Stop the PostgreSQL container by running this command from inside the secure-notes/ folder:
docker compose stop
  • When you are ready to resume, start the container again with:
docker compose start
  • Then run npm run dev to start your Next.js app as normal.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes the container, the database volume, and all local files.

  • From inside the secure-notes/ folder, remove the Docker container and its data volume:
docker compose down -v
  • Navigate up one level and delete the project folder.

macOS

cd ~/Desktop
rm -rf secure-notes

Windows

cd ~\Desktop
rmdir /s /q secure-notes
  • Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop.

Nice Work!

Nice Work!

You just built a Next.js notes app, broke it on purpose, and hardened it against the most common web vulnerabilities. You now have firsthand experience thinking like both an attacker and a defender.

You've learned how to:

  • Build a Next.js Server Actions app connected to PostgreSQL, and exploit SQL injection, IDOR, and XSS vulnerabilities in your own code.
  • Fix each vulnerability using Zod validation, parameterized queries via tagged templates, and per-action authorization checks.
  • Configure Content Security Policy and other security headers in next.config.ts to protect the browser layer.
  • Secret Mission: Restructure the app with a Data Access Layer that enforces authorization at the query level, making unauthorized access structurally impossible.

Ready to quiz yourself?