AI API Security Auditor

Scan any public API endpoint and get an AI-powered security report card.

Introduction

30 Second Summary

Every app you use talks to APIs behind the scenes. When those APIs ship without basic security protections, attackers can intercept data, hijack sessions, or spoof responses.

In this project, you will build a web app that scans any public API endpoint and generates an AI-powered security report card. You paste a URL, click Scan, and receive a scored audit with prioritized findings and fix recommendations.

What You'll Build

You'll paste a URL into your scanner, click Scan, and watch a polished report card appear with a color-coded security score, severity-ranked findings, and exact code snippets for hardening the endpoint.

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

  • A scan pipeline that safely fetches any public API endpoint with SSRF protection, runs 10 security checks against the response headers, and produces structured pass/warn/fail findings with evidence.
  • An AI analysis layer powered by Claude that scores every finding, explains its real-world impact, and recommends exactly how to fix it.
  • A visual report card displaying a color-coded score badge, severity-ranked finding cards you can expand for details, and pass/fail stats at a glance.
  • Secret Mission: Re-scan an endpoint and compare results against a previous audit to track resolved, new, and unchanged findings over time.

Are there any prerequisites?

You'll need Node.js installed, an Anthropic API key, and familiarity with TypeScript, the Next.js App Router, and REST APIs.

Before We Start

Before we start writing code, let's get clear on what you're building and why it matters.

This is your moment to commit to the project goal before any hands-on work begins.

Scaffold the Project and Install Dependencies

You have a clear goal: build a passive API security scanner powered by AI. Before you can write any scanning logic, you need a working development environment with the right framework and libraries in place.

In this step you will scaffold a Next.js project with TypeScript and the App Router, then install the Anthropic SDK for AI analysis and Zod for schema validation.

In this step, get ready to:
  • Create a Next.js project with App Router, TypeScript, and Tailwind CSS.
  • Install the Anthropic SDK and Zod as project dependencies.
  • Configure your API key and verify the dev server runs.
Create the Next.js project

Next.js provides a scaffold tool called create-next-app that generates a ready-to-run project with your chosen configuration. The flags you pass tell it exactly what to include, so it skips all interactive prompts.

  • 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 your Next.js project by running this command:
npx create-next-app@latest api-security-auditor --ts --app --tailwind --eslint --use-npm

What does this command do?

  • npx create-next-app@latest downloads and runs the latest Next.js scaffold tool.
  • api-security-auditor is the project folder name.
  • --ts enables TypeScript, --app uses App Router, --tailwind adds Tailwind CSS, and --eslint configures ESLint.
  • --use-npm ensures npm is the package manager.
  • Because you provided flags, the CLI skips all interactive prompts and uses recommended defaults for everything else (no React Compiler, no src/ directory, import alias @/*).

The command downloads dependencies and creates the project. This takes about 30-60 seconds depending on your connection.

  • Once it finishes, move into your new project folder by running:
cd api-security-auditor
  • Confirm the project files exist by running:
ls

You should see files like package.json, tsconfig.json, next.config.ts, and folders like app/ and node_modules/.

Command not found or download failed?

  • Make sure you have Node.js 20.9.0 or later installed. Run node --version to check.
  • If npx hangs, check your internet connection. The command needs to download packages from npm.

Help me troubleshoot my create-next-app command. I ran npx create-next-app@latest api-security-auditor --ts --app --tailwind --eslint --use-npm and it failed.

Install the Anthropic SDK and Zod

Your project needs two additional libraries: the Anthropic SDK to call Claude for AI analysis, and Zod to validate both incoming requests and AI output schemas.

  • Install both packages by running this command:
npm install @anthropic-ai/sdk zod

What are these packages?

  • @anthropic-ai/sdk is the official TypeScript client for the Claude API. It reads your API key from the environment automatically.
  • zod is a schema validation library. You will use it to validate user input and to guarantee Claude returns data in the exact shape your report card needs.

You should see output confirming both packages were added to your package.json.

Install failed with permission errors?

  • Never run npm install with sudo. If you see permission errors, check that you are inside the api-security-auditor folder (run pwd to confirm).
  • If you see network errors, retry the command. Temporary npm registry issues are common.

Help me fix my npm install error for @anthropic-ai/sdk and zod.

Configure your API key and start the dev server

The Anthropic SDK automatically reads your API key from an environment variable called ANTHROPIC_API_KEY. Next.js loads variables from a .env.local file in your project root.

  • Create the .env.local file inside your api-security-auditor folder by running:
echo 'ANTHROPIC_API_KEY=your-api-key-here' > .env.local
  • Open .env.local in a text editor and replace your-api-key-here with your actual Anthropic API key.
  • Save the file.

Why .env.local?

Next.js automatically loads .env.local into process.env on the server side. This file is already listed in .gitignore by the scaffold, so your key stays out of version control.

Before you run the dev server, what do you expect to see at http://localhost:3000? You have not written any custom code yet, so think about what the scaffold provides by default.

  • Start the development server by running:
npm run dev

Open http://localhost:3000 in your browser. You should see the default Next.js starter page with the Next.js logo and links to documentation.

Page not loading at localhost:3000?

  • Check your terminal for errors. If you see a port conflict, another process is using port 3000. Stop it or Next.js will try the next available port (it prints the actual URL).
  • Make sure you ran npm run dev from inside the api-security-auditor folder.

Help me get my Next.js dev server running. I ran npm run dev but nothing appears at localhost:3000.

✔️ Awesome, I've got everything!

Great. Double check you have saved your .env.local file with your real API key.

ⓧ I'd like to double check the full code

Your .env.local file should contain exactly this (with your real key substituted):

ANTHROPIC_API_KEY=your-api-key-here

Your development environment is ready. Next up, you will build the scan form and the server-side endpoint fetcher that safely retrieves headers from any public API URL.

Build the Scan Form and Server-Side Endpoint Fetcher

Your project is scaffolded and the dev server is running. Now you need a way to actually scan an API endpoint.

In this step, you will build the core fetch pipeline: a form where the learner pastes a URL, a server-side Route Handler that fetches that URL safely, and protection against SSRF attacks that could let someone abuse your server to probe internal networks.

In this step, get ready to:
  • Create a Zod schema that validates scan request URLs.
  • Build an SSRF protection module that blocks requests to private IP ranges.
  • Wire up a Route Handler and React form that display raw response headers on screen.
Create the URL validation schema and SSRF protection

Before your server fetches anything, it needs to validate that the incoming URL is well-formed and safe. You will use Zod to enforce URL format rules, then build a dedicated module that blocks requests targeting private networks.

  • Create a new folder called lib at the root of your api-security-auditor project.
  • Create a new file called schemas.ts inside the lib folder and add the following code:
import { z } from "zod";

export const ScanRequestSchema = z.object({
  url: z
    .string()
    .url("Must be a valid URL")
    .refine(
      (url) => {
        try {
          const parsed = new URL(url);
          return parsed.protocol === "http:" || parsed.protocol === "https:";
        } catch {
          return false;
        }
      },
      { message: "URL must use http or https protocol" }
    )
    .refine(
      (url) => {
        try {
          const parsed = new URL(url);
          return !parsed.username && !parsed.password;
        } catch {
          return false;
        }
      },
      { message: "URL must not contain embedded credentials" }
    ),
});

export type ScanRequest = z.infer<typeof ScanRequestSchema>;

What does this code do?

The schema validates three things about any URL the user submits. First, .url() confirms it is a well-formed URL. Then the first .refine() ensures only http: or https: protocols are used, blocking file:// or ftp:// tricks.

The second .refine() rejects URLs with embedded credentials like http://admin:pass@target.com. Attackers use these to smuggle auth headers into server-side requests.

  • Save lib/schemas.ts.
  • Now create a file called ssrf.ts in the same lib folder and add the IP blocking helpers:
import { URL } from "url";
import * as dns from "dns/promises";
import * as net from "net";

const BLOCKED_IPV4_RANGES = [
  { prefix: "127.", label: "loopback" },
  { prefix: "10.", label: "private (10.x)" },
  { prefix: "192.168.", label: "private (192.168.x)" },
  { prefix: "169.254.", label: "link-local" },
  { prefix: "0.", label: "unspecified" },
];

function isBlockedIPv4(ip: string): boolean {
  if (BLOCKED_IPV4_RANGES.some((r) => ip.startsWith(r.prefix))) return true;
  if (ip.startsWith("172.")) {
    const second = parseInt(ip.split(".")[1], 10);
    if (second >= 16 && second <= 31) return true;
  }
  return false;
}

function isBlockedIPv6(ip: string): boolean {
  const normalized = ip.toLowerCase();
  return normalized === "::1" || normalized === "::";
}

What is SSRF?

SSRF (Server-Side Request Forgery) is an attack where someone tricks your server into making requests to internal services. Without protection, a user could submit http://127.0.0.1:3000 and probe your own machine.

The blocked ranges cover all RFC 1918 private addresses (10.x, 172.16-31.x, 192.168.x), loopback (127.x), link-local (169.254.x), and IPv6 loopback (::1).

  • Still in lib/ssrf.ts, add the validateUrl function below the helpers. This is the first part, handling direct IP and protocol checks:
export async function validateUrl(urlString: string): Promise<void> {
  const parsed = new URL(urlString);

  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
    throw new Error("Only http and https protocols are allowed");
  }

  if (parsed.username || parsed.password) {
    throw new Error("URLs with embedded credentials are not allowed");
  }

  const hostname = parsed.hostname.replace(/^\[|\]$/g, "");

  if (net.isIPv4(hostname)) {
    if (isBlockedIPv4(hostname)) {
      throw new Error("Requests to private/reserved IP addresses are blocked");
    }
    return;
  }

  if (net.isIPv6(hostname)) {
    if (isBlockedIPv6(hostname)) {
      throw new Error("Requests to private/reserved IP addresses are blocked");
    }
    return;
  }
  • Continue in the same function. Add the DNS resolution checks directly below (still inside validateUrl):
  const addresses = await dns.resolve4(hostname).catch(() => []);
  const addresses6 = await dns.resolve6(hostname).catch(() => []);
  const allAddresses = [...addresses, ...addresses6];

  if (allAddresses.length === 0) {
    throw new Error("DNS resolution failed for the provided hostname");
  }

  for (const addr of addresses) {
    if (isBlockedIPv4(addr)) {
      throw new Error(
        "Hostname resolves to a private/reserved IP address"
      );
    }
  }

  for (const addr of addresses6) {
    if (isBlockedIPv6(addr)) {
      throw new Error(
        "Hostname resolves to a private/reserved IP address"
      );
    }
  }
}

Why resolve DNS before fetching?

An attacker can register a public domain name that resolves to 127.0.0.1. Checking only the hostname text would miss this. By resolving DNS first and checking the resolved IP addresses against the blocklist, you catch DNS rebinding attacks before any connection is made.

  • Save lib/ssrf.ts.
  • Confirm both files were created by running:
ls lib/

You should see schemas.ts and ssrf.ts listed.

Don't see both files?

Make sure you created the lib folder at the project root (same level as app), not inside the app directory.

help me find where my lib folder should go in my Next.js project

Build the Route Handler with safe fetching

Now you need a server-side endpoint that receives the URL from the form, validates it, runs the SSRF checks, and fetches the target safely. Next.js Route Handlers run entirely on the server, so the learner's browser never makes a direct cross-origin request.

  • Create the folder path app/api/scan/ inside your project.
  • Create route.ts inside that folder and add the imports, constants, and the beginning of the safeFetch function:
import { NextRequest, NextResponse } from "next/server";
import { ScanRequestSchema } from "@/lib/schemas";
import { validateUrl } from "@/lib/ssrf";

const MAX_REDIRECTS = 5;
const TIMEOUT_MS = 10000;
const MAX_RESPONSE_SIZE = 1_048_576;

async function safeFetch(
  url: string,
  redirectCount = 0
): Promise<{ finalUrl: string; status: number; headers: Record<string, string> }> {
  if (redirectCount > MAX_REDIRECTS) {
    throw new Error("Too many redirects");
  }

  await validateUrl(url);

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);

What do these constants control?

  • MAX_REDIRECTS = 5 prevents infinite redirect loops. Each redirect hop is re-checked against the SSRF blocklist.
  • TIMEOUT_MS = 10000 aborts requests that hang longer than 10 seconds using an AbortController.
  • MAX_RESPONSE_SIZE = 1_048_576 caps responses at 1 MB to prevent memory exhaustion.
  • Continue in the same file. Add the try/finally block that performs the actual fetch, directly below the setTimeout line:
  try {
    const response = await fetch(url, {
      method: "GET",
      redirect: "manual",
      signal: controller.signal,
      headers: { "User-Agent": "API-Security-Auditor/1.0" },
    });

    const contentLength = response.headers.get("content-length");
    if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_SIZE) {
      throw new Error("Response too large (exceeds 1MB limit)");
    }

    if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
      const redirectUrl = new URL(response.headers.get("location")!, url).toString();
      return safeFetch(redirectUrl, redirectCount + 1);
    }

    const headers: Record<string, string> = {};
    response.headers.forEach((value, key) => {
      headers[key.toLowerCase()] = value;
    });

    return { finalUrl: url, status: response.status, headers };
  } finally {
    clearTimeout(timeout);
  }
}

Why manual redirects?

Setting redirect: "manual" prevents the fetch API from auto-following redirects. This lets your code inspect each redirect destination and re-run the SSRF blocklist check on it. Without this, an attacker could redirect from a public URL to http://127.0.0.1 and bypass your protection.

  • Complete the file by adding the POST handler below the safeFetch function:
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const parsed = ScanRequestSchema.safeParse(body);

    if (!parsed.success) {
      return NextResponse.json(
        { error: "Invalid request", details: parsed.error.issues },
        { status: 400 }
      );
    }

    const { url } = parsed.data;

    let fetchResult;
    try {
      fetchResult = await safeFetch(url);
    } catch (err) {
      const message = err instanceof Error ? err.message : "Failed to fetch endpoint";
      return NextResponse.json({ error: message }, { status: 422 });
    }

    return NextResponse.json({
      headers: fetchResult.headers,
      status: fetchResult.status,
      finalUrl: fetchResult.finalUrl,
    });
  } catch {
    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
  }
}

How does the error handling work?

The handler uses two layers of error handling. The inner try/catch around safeFetch catches specific scan failures (SSRF blocks, timeouts, DNS failures) and returns a 422 with the exact error message. The outer try/catch catches unexpected crashes and returns a generic 500.

  • Save app/api/scan/route.ts.
  • Check the terminal where npm run dev is running. You should see no compilation errors. If you see a red error about missing modules, double-check your lib/ folder location.

Seeing a module resolution error?

The @/lib/schemas import uses the path alias configured by Next.js. Make sure the lib folder is at the project root (next to app/), not inside app/.

help me fix a module not found error for @/lib/schemas in my Next.js project

Build the scan form and test the pipeline

The backend is ready. Now you need a frontend form to send URLs to it. You will replace the default app/page.tsx with a scan form that POSTs to your new Route Handler and displays raw response headers.

  • Open app/page.tsx in your editor. Select all the existing content and replace it with the component logic below:
"use client";

import { useState } from "react";

export default function Home() {
  const [url, setUrl] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [headers, setHeaders] = useState<Record<string, string> | null>(null);

  async function handleScan(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    setHeaders(null);
    setLoading(true);

    try {
      const response = await fetch("/api/scan", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ url }),
      });

      const data = await response.json();

      if (!response.ok) {
        setError(data.error || "Scan failed");
        return;
      }

      setHeaders(data.headers);
    } catch {
      setError("Network error. Please check your connection and try again.");
    } finally {
      setLoading(false);
    }
  }

What does handleScan do?

  • Clears previous results and sets the loading state.
  • POSTs the URL to /api/scan as JSON.
  • If the response is not OK, extracts the error message from the JSON body and shows it.
  • On success, stores the response headers in state for display.
  • Still in the same file, add the JSX return statement directly below the handleScan function (still inside the Home component):
  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-3xl mx-auto space-y-8">
        <div className="text-center">
          <h1 className="text-3xl font-bold text-gray-900">API Security Auditor</h1>
          <p className="mt-2 text-gray-600">Paste a public API endpoint to scan its security headers.</p>
        </div>
        <form onSubmit={handleScan} className="flex gap-3">
          <input
            type="url"
            value={url}
            onChange={(e) => setUrl(e.target.value)}
            placeholder="https://api.example.com/v1/health"
            required
            className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
          />
          <button
            type="submit"
            disabled={loading}
            className="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? "Scanning..." : "Scan"}
          </button>
        </form>
        {error && (
          <div className="p-4 bg-red-50 border border-red-200 rounded-lg">
            <p className="text-red-800">{error}</p>
          </div>
        )}
        {headers && (
          <pre className="bg-white p-4 rounded-lg border text-sm overflow-auto">
            {JSON.stringify(headers, null, 2)}
          </pre>
        )}
      </div>
    </main>
  );
}

What does the UI render?

  • A URL input with an HTML5 type="url" attribute for browser-level validation.
  • A submit button that shows "Scanning..." while the request is in flight.
  • A red error box if something goes wrong (invalid URL, SSRF block, timeout).
  • A <pre> block that displays the raw JSON headers when the scan succeeds.
  • Save app/page.tsx.

Before you test, what do you expect to see when you scan a public API? Think about what HTTP headers a real API like GitHub's returns.

  • Switch to your browser and open http://localhost:3000.
  • Enter https://api.github.com in the input field.
  • Click Scan.

You should see a JSON dump of response headers appear below the form. Look for headers like x-github-request-id, content-type, and x-frame-options in the output.

Not seeing headers?

  • Check your terminal for compilation errors. A red error typically means a typo in an import path.
  • If you see "Network error", make sure npm run dev is still running.
  • If you see "DNS resolution failed", check your internet connection.

help me debug why my scan form isn't returning headers from the API endpoint

  • Test the SSRF protection by entering http://127.0.0.1:3000 and clicking Scan.

You should see a red error message saying "Requests to private/reserved IP addresses are blocked". This confirms your SSRF protection is working.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: lib/schemas.ts, lib/ssrf.ts, app/api/scan/route.ts, and app/page.tsx.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these sections is present in your files, in order:

  • lib/schemas.ts: ScanRequestSchema with .url() and two .refine() calls, plus the ScanRequest type export.
  • lib/ssrf.ts: imports, BLOCKED_IPV4_RANGES array, isBlockedIPv4 function, isBlockedIPv6 function, then the full validateUrl function with protocol checks, direct IP checks, and DNS resolution checks.
  • app/api/scan/route.ts: imports, three constants, the safeFetch function (with AbortController, manual redirect following, and header extraction), then the POST handler with Zod validation and nested error handling.
  • app/page.tsx: "use client" directive, four useState hooks, handleScan function, and the JSX with form, error display, and <pre> headers output.

Your scan pipeline is working end-to-end. You can fetch any public endpoint and see its raw headers. Next up, you will turn those raw headers into meaningful security findings with programmatic checks.

Add Deterministic Security Checks

Your scanner can fetch any public URL and return its raw response headers. That proves the plumbing works.

But a wall of raw headers tells you nothing about security posture. What you need is programmatic logic that inspects each header and produces structured pass/fail verdicts with evidence. In this step, you will build a checks module that interprets 10 security-relevant headers and returns clear findings.

In this step, get ready to:
  • Create a security checks module that evaluates HTTP response headers against 10 common security best practices.
  • Wire the checks into your Route Handler so findings are returned alongside raw headers.
  • Update the page to display findings as a pass/warn/fail list instead of raw JSON.
Define the Finding schema and first checks

Each security check produces a finding with a consistent shape. You need a type for this before building the checks module.

  • In lib/schemas.ts, add the following code below your existing ScanRequest type export:
export const FindingSchema = z.object({
  checkId: z.string(),
  title: z.string(),
  status: z.enum(["pass", "warn", "fail"]),
  evidence: z.string(),
  category: z.string(),
});

export type Finding = z.infer<typeof FindingSchema>;

What does this schema define?

Each finding has five fields. checkId is a unique key (like hsts). status is one of pass, warn, or fail. evidence captures the actual header value (or its absence) that triggered the verdict.

  • Save lib/schemas.ts.

Now create the checks module. This function receives the fetched response data and returns an array of findings.

  • Create a new file lib/checks.ts and add the following code:
import { Finding } from "./schemas";

interface FetchResult {
  url: string;
  status: number;
  headers: Record<string, string>;
  usesHttps: boolean;
}

export function runSecurityChecks(result: FetchResult): Finding[] {
  const findings: Finding[] = [];
  const h = result.headers;

  // Check if connection uses HTTPS
  findings.push({
    checkId: "https",
    title: "HTTPS Enforcement",
    status: result.usesHttps ? "pass" : "fail",
    evidence: result.usesHttps ? "Connection uses HTTPS" : "Connection uses plain HTTP",
    category: "Transport Security",
  });

  return findings;
}

What does this code do?

The FetchResult interface describes the data shape that runSecurityChecks expects. It includes the response headers, the final URL, and whether HTTPS was used.

The first check is simple: if the URL starts with https:// then transport security passes. Otherwise it fails. This establishes the pattern every other check follows.

  • Save lib/checks.ts.

Wire this into your Route Handler so you can see it working immediately.

  • In app/api/scan/route.ts, find this import line at the top:
import { ScanRequestSchema } from "@/lib/schemas";
  • Replace it with these two imports:
import { ScanRequestSchema, Finding } from "@/lib/schemas";
import { runSecurityChecks } from "@/lib/checks";
  • Next, find the return statement inside the POST function that currently looks like this:
    return NextResponse.json({
      headers: fetchResult.headers,
      status: fetchResult.status,
      finalUrl: fetchResult.finalUrl,
    });
  • Replace it with this code that runs the checks and returns findings:
    const findings: Finding[] = runSecurityChecks({
      url: fetchResult.finalUrl,
      status: fetchResult.status,
      headers: fetchResult.headers,
      usesHttps: fetchResult.finalUrl.startsWith("https://"),
    });
    return NextResponse.json({ findings, headers: fetchResult.headers });

What changed in the Route Handler?

After the successful fetch, you now pass the result through runSecurityChecks. The response returns both the raw headers (for debugging) and the structured findings array.

  • Save app/api/scan/route.ts.
  • Return to your browser at http://localhost:3000, enter https://api.github.com and click Scan.

You should see a JSON response that now includes a findings array with one entry showing HTTPS Enforcement with status pass. The pipeline works.

Not seeing findings in the response?

Make sure you imported runSecurityChecks from @/lib/checks and Finding from @/lib/schemas. Check your terminal for TypeScript errors pointing to the issue.

help me debug why my findings array is empty or missing from the scan response

Add the remaining security checks

One check proves the pattern works. Now add the remaining nine checks that cover transport security, content security, privacy, and access control.

  • In lib/checks.ts, find the line return findings; and add the following code above it:
  // Check Strict-Transport-Security header and max-age
  const hsts = h["strict-transport-security"];
  if (hsts) {
    const maxAgeMatch = hsts.match(/max-age=(\d+)/);
    const maxAge = maxAgeMatch ? parseInt(maxAgeMatch[1], 10) : 0;
    findings.push({
      checkId: "hsts",
      title: "Strict-Transport-Security",
      status: maxAge >= 31536000 ? "pass" : "warn",
      evidence: hsts,
      category: "Transport Security",
    });
  } else {
    findings.push({
      checkId: "hsts",
      title: "Strict-Transport-Security",
      status: "fail",
      evidence: "Header not present",
      category: "Transport Security",
    });
  }

  // Check Content-Security-Policy
  const csp = h["content-security-policy"];
  findings.push({
    checkId: "csp",
    title: "Content-Security-Policy",
    status: csp ? "pass" : "fail",
    evidence: csp || "Header not present",
    category: "Content Security",
  });

Why does HSTS check max-age?

Simply having the Strict-Transport-Security header isn't enough. If max-age is too short, browsers forget the HTTPS-only instruction quickly. A value of 31536000 (one year) is the industry baseline for production.

  • Still in lib/checks.ts, add these content security and privacy checks above return findings;:
  // Check X-Content-Type-Options
  const xcto = h["x-content-type-options"];
  findings.push({
    checkId: "xcto",
    title: "X-Content-Type-Options",
    status: xcto?.toLowerCase() === "nosniff" ? "pass" : "fail",
    evidence: xcto || "Header not present",
    category: "Content Security",
  });

  // Check X-Frame-Options
  const xfo = h["x-frame-options"];
  findings.push({
    checkId: "xfo",
    title: "X-Frame-Options",
    status: xfo ? "pass" : "warn",
    evidence: xfo || "Header not present",
    category: "Content Security",
  });

  // Check Referrer-Policy
  const referrer = h["referrer-policy"];
  findings.push({
    checkId: "referrer",
    title: "Referrer-Policy",
    status: referrer ? "pass" : "warn",
    evidence: referrer || "Header not present",
    category: "Privacy",
  });

What do these checks evaluate?

X-Content-Type-Options prevents browsers from guessing file types (MIME sniffing), which can lead to script execution. X-Frame-Options blocks clickjacking by preventing your page from loading in an iframe. Referrer-Policy controls how much URL info leaks to other sites when users follow links.

  • Save lib/checks.ts and scan https://api.github.com again.

You should now see 6 findings in the JSON response. The pattern is working at scale.

  • Add the final four checks above return findings; in lib/checks.ts:
  // Check Permissions-Policy
  const permissions = h["permissions-policy"];
  findings.push({
    checkId: "permissions",
    title: "Permissions-Policy",
    status: permissions ? "pass" : "warn",
    evidence: permissions || "Header not present",
    category: "Privacy",
  });

  // Check Cache-Control
  const cacheControl = h["cache-control"];
  findings.push({
    checkId: "cache",
    title: "Cache-Control",
    status: cacheControl ? "pass" : "warn",
    evidence: cacheControl || "Header not present",
    category: "Data Protection",
  });
  • Add the CORS configuration check and server version exposure check above return findings;:
  // Check CORS configuration
  const acao = h["access-control-allow-origin"];
  if (acao === "*") {
    findings.push({
      checkId: "cors",
      title: "CORS Configuration",
      status: "warn",
      evidence: "Access-Control-Allow-Origin: *",
      category: "Access Control",
    });
  } else {
    findings.push({
      checkId: "cors",
      title: "CORS Configuration",
      status: "pass",
      evidence: acao ? `Access-Control-Allow-Origin: ${acao}` : "No CORS header (same-origin only)",
      category: "Access Control",
    });
  }

  // Check for exposed server version headers
  const server = h["server"];
  const xPoweredBy = h["x-powered-by"];
  const versionExposed = server || xPoweredBy;
  findings.push({
    checkId: "server-info",
    title: "Server Version Exposure",
    status: versionExposed ? "warn" : "pass",
    evidence: versionExposed
      ? `Server: ${server || "N/A"}, X-Powered-By: ${xPoweredBy || "N/A"}`
      : "No server version headers exposed",
    category: "Information Disclosure",
  });

Why is Access-Control-Allow-Origin: * a warning?

A wildcard CORS header means any website can make requests to that API from the browser. For public read-only APIs this is often intentional, so it earns a warn rather than a fail. The server version check flags the opposite: information that should be hidden (like Server: nginx/1.19.0) because it helps attackers target known vulnerabilities.

  • Save lib/checks.ts.
  • Scan https://api.github.com again and confirm you see 10 findings in the JSON response.

You should now see the full set of 10 checks including CORS and server version exposure.

Seeing fewer than 10 findings?

Make sure each code block was pasted above return findings; and not below it. Any code after the return is unreachable.

help me figure out why my checks.ts only returns some findings instead of all 10

✔️ Awesome, I've got everything!

Great. Make sure lib/checks.ts is saved.

ⓧ I'd like to double check the full code

Your lib/checks.ts should contain these sections in order. Scroll up and confirm each one is present:

  • Import of Finding from ./schemas.
  • FetchResult interface with url, status, headers, and usesHttps fields.
  • runSecurityChecks function containing 10 push calls (https, hsts, csp, xcto, xfo, referrer, permissions, cache, cors, server-info).
  • return findings; at the end of the function.
Display findings as a structured list

The pre-block JSON dump served its purpose for debugging. Now replace it with a proper findings list so each result is visible at a glance.

  • Replace the entire contents of app/page.tsx with the following code:
"use client";

import { useState } from "react";
import { Finding } from "@/lib/schemas";

export default function Home() {
  const [url, setUrl] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [findings, setFindings] = useState<Finding[] | null>(null);

  async function handleScan(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    setFindings(null);
    setLoading(true);
    try {
      const response = await fetch("/api/scan", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ url }),
      });
  • Continue adding the rest of the component below what you just pasted in app/page.tsx:
      const data = await response.json();
      if (!response.ok) { setError(data.error || "Scan failed"); return; }
      setFindings(data.findings);
    } catch {
      setError("Network error. Please check your connection and try again.");
    } finally {
      setLoading(false);
    }
  }

  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-3xl mx-auto space-y-8">
        <div className="text-center">
          <h1 className="text-3xl font-bold text-gray-900">API Security Auditor</h1>
        </div>
  • Finish the component by adding the form and findings display below the previous code in app/page.tsx:
        <form onSubmit={handleScan} className="flex gap-3">
          <input type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://api.example.com" required className="flex-1 px-4 py-3 border border-gray-300 rounded-lg outline-none" />
          <button type="submit" disabled={loading} className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50">{loading ? "Scanning..." : "Scan"}</button>
        </form>
        {error && <div className="p-4 bg-red-50 border border-red-200 rounded-lg"><p className="text-red-800">{error}</p></div>}
        {findings && (
          <ul className="space-y-2">
            {findings.map((f) => (
              <li key={f.checkId} className="flex gap-3 p-3 bg-white border rounded-lg">
                <span className={f.status === "pass" ? "text-green-600" : f.status === "warn" ? "text-yellow-600" : "text-red-600"}>{f.status.toUpperCase()}</span>
                <span className="font-medium">{f.title}</span>
                <span className="text-gray-500 text-sm">{f.evidence}</span>
              </li>
            ))}
          </ul>
        )}
      </div>
    </main>
  );
}

What changed in the page?

The component now imports the Finding type and stores the findings array instead of raw headers. Each finding renders as a list item with a color-coded status label (green for pass, yellow for warn, red for fail), the check title, and the raw evidence.

  • Save app/page.tsx.

Before you run this, what do you think the results will look like for a major public API like GitHub's?

  • Enter https://api.github.com in the scan form and click Scan.

You should see a list of 10 check results with color-coded verdicts. Some pass (like HTTPS Enforcement and X-Content-Type-Options) and others fail or warn (like Content-Security-Policy which is typically missing from API endpoints).

Page shows a blank screen or TypeScript error?

Check your terminal for compilation errors. The most common issue is a missing import. Confirm that import { Finding } from "@/lib/schemas" is at the top of app/page.tsx and that FindingSchema is exported from lib/schemas.ts.

help me fix a TypeScript error in my page.tsx after adding the Finding type import

Notice what's missing?

The results are technically correct. You can see which checks pass and which fail. But they are flat and unhelpful.

There is no explanation of why a failing check matters. There is no severity ranking to tell you which issue to fix first. There is no recommended fix showing what header value to set. A developer looking at this list still doesn't know what to do next.

That gap between raw verdicts and actionable guidance is exactly what AI fills in the next step. Claude will analyze these findings and produce a scored, prioritized report with explanations and fix recommendations.

Integrate Claude for Structured Security Analysis

Your scanner catches real security issues, but the flat pass/warn/fail list leaves the learner guessing why each finding matters and what to do about it.

In this step, you'll send those raw findings to Claude for structured analysis. Claude will score the endpoint's overall security posture, prioritize each finding by severity, explain the real-world impact, and recommend specific fixes.

In this step, get ready to:
  • Define a Zod schema for Claude's structured security report.
  • Build an analysis function that sends findings to Claude and parses the response.
  • Wire the analysis into the Route Handler and display the AI report.
Define the AI response schema

Claude needs to return a predictable shape every time. You'll define that shape as a Zod schema so the SDK can enforce it at the API level.

  • In lib/schemas.ts, scroll to the bottom of the file (below the Finding type export).
  • Add the SecurityReportSchema and its type export by pasting this code at the end of the file:
export const SecurityReportSchema = z.object({
  overallScore: z.number().min(0).max(100),
  riskLevel: z.enum(["critical", "high", "medium", "low"]),
  summary: z.string(),
  findings: z.array(
    z.object({
      checkId: z.string(),
      severity: z.enum(["critical", "high", "medium", "low", "info"]),
      title: z.string(),
      explanation: z.string(),
      impact: z.string(),
      recommendation: z.string(),
      evidence: z.string(),
    })
  ),
  passedChecks: z.array(
    z.object({
      checkId: z.string(),
      title: z.string(),
      evidence: z.string(),
    })
  ),
});

export type SecurityReport = z.infer<typeof SecurityReportSchema>;

What does this schema define?

This schema describes the exact shape Claude must return. It includes an overallScore from 0-100, a riskLevel enum, a summary string, a findings array with severity/explanation/impact/recommendation for each issue, and a passedChecks array.

The Anthropic SDK uses this schema to constrain Claude's output at generation time. If Claude tries to return something that doesn't match, the SDK rejects it.

  • Save lib/schemas.ts.
Build the analysis function

The analysis function will take raw findings, send them to Claude with a carefully crafted system prompt, and return a guaranteed-schema report.

  • Create a new file lib/analyze.ts and add the imports, client initialization, and system prompt:
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { Finding, SecurityReport, SecurityReportSchema } from "./schemas";

// Initialize the Anthropic client (reads ANTHROPIC_API_KEY from env)
const client = new Anthropic();

// System prompt positions Claude as a security analyst
const SYSTEM_PROMPT = `You are an API security analyst. You receive the results of a passive security scan (HTTP headers, HTTPS status, CORS configuration) of a public API endpoint.

Your job is to:
1. Score the endpoint's security posture from 0 (critical risk) to 100 (excellent).
2. Assign a risk level: critical (0-25), high (26-50), medium (51-75), low (76-100).
3. Write a brief summary of the overall security posture.
4. For each failing or warning check, provide: severity, a plain-language explanation of why it matters, the potential impact if exploited, and a specific actionable recommendation to fix it.
5. List checks that passed with their evidence.

Important constraints:
- Base your analysis ONLY on the provided scan findings. Do not invent findings.
- This is an informational report, NOT a formal penetration test or security certification.
- Be specific in recommendations. Include header values and configuration examples.
- Prioritize findings by real-world exploitability, not theoretical risk.`;

What does the system prompt do?

The system prompt tells Claude exactly what role to play and what constraints to follow. It enforces that Claude only analyzes the provided findings (no hallucinated issues) and marks the output as informational rather than a formal certification.

The scoring bands (0-25 critical, 26-50 high, etc.) give Claude a consistent rubric so scores are comparable across scans.

  • Below the SYSTEM_PROMPT constant in the same file, add the analyzeFindings function:
export async function analyzeFindings(
  url: string,
  statusCode: number,
  findings: Finding[]
): Promise<SecurityReport> {
  // Package the scan data as structured JSON for Claude
  const userContent = JSON.stringify({ endpoint: url, statusCode, scanTimestamp: new Date().toISOString(), findings }, null, 2);

  // Use messages.parse() with zodOutputFormat for guaranteed schema output
  const response = await client.messages.parse({
    model: "claude-sonnet-4-5-20250929",
    max_tokens: 4096,
    system: SYSTEM_PROMPT,
    messages: [{ role: "user", content: `Analyze these security scan findings and produce a structured report:\n\n${userContent}` }],
    output_config: { format: zodOutputFormat(SecurityReportSchema) },
  });

  return response.parsed_output!;
}

What does this function do?

client.messages.parse() sends the request to Claude and automatically validates the response against your Zod schema. The zodOutputFormat(SecurityReportSchema) helper converts your Zod schema into the format Claude's API expects.

The returned response.parsed_output is already typed as SecurityReport. No manual parsing needed.

  • Save lib/analyze.ts.
  • Switch to the terminal running your dev server. Confirm there are no TypeScript compile errors.

Seeing a TypeScript error?

If you see an error about SecurityReportSchema not being exported, make sure you saved lib/schemas.ts with the new schema at the bottom of the file.

If you see an error about the @anthropic-ai/sdk import, confirm you ran npm install @anthropic-ai/sdk in Step 1.

Help me debug my analyze.ts TypeScript error.

Wire the analysis into the Route Handler and update the page

The analysis function is ready. Now you need to call it from the Route Handler and update the page to display the structured report.

  • In app/api/scan/route.ts, find the import lines at the top of the file.
  • Add this import below the existing ones:
import { analyzeFindings } from "@/lib/analyze";
  • Now find this line near the bottom of the POST function:
    return NextResponse.json({ findings, headers: fetchResult.headers });
  • Replace that single return line with the following code that calls Claude and handles failures gracefully:
    // Call Claude for structured analysis, with fallback if it fails
    let report;
    try {
      report = await analyzeFindings(url, fetchResult.status, findings);
    } catch {
      return NextResponse.json({
        report: null,
        findings,
        headers: fetchResult.headers,
        error: "AI analysis unavailable, showing raw findings",
      });
    }
    return NextResponse.json({
      report,
      findings,
      headers: fetchResult.headers,
      scannedUrl: url,
      finalUrl: fetchResult.finalUrl,
      statusCode: fetchResult.status,
      timestamp: new Date().toISOString(),
    });

Why the try/catch fallback?

The Claude API call can fail for many reasons: rate limits, network issues, or an expired API key. The try/catch ensures the scanner still returns useful raw findings even when AI enrichment is unavailable.

  • Save app/api/scan/route.ts.

Now update the page to handle the new structured response from the API.

  • Replace the entire contents of app/page.tsx with the following code:
"use client";

import { useState } from "react";
import { SecurityReport } from "@/lib/schemas";

// Shape of a successful scan response
interface ScanResult {
  report: SecurityReport;
  scannedUrl: string;
  timestamp: string;
}

export default function Home() {
  const [url, setUrl] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [result, setResult] = useState<ScanResult | null>(null);

What changed?

The page now imports SecurityReport instead of Finding. State tracks a full ScanResult object (with report, URL, and timestamp) instead of a raw findings array.

  • Directly below the state declarations (still inside the Home component), add the updated handler and JSX:
  async function handleScan(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    setResult(null);
    setLoading(true);
    try {
      const response = await fetch("/api/scan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }) });
      const data = await response.json();
      if (!response.ok) { setError(data.error || "Scan failed"); return; }
      if (data.report) {
        setResult({ report: data.report, scannedUrl: data.scannedUrl, timestamp: data.timestamp });
      } else {
        setError(data.error || "No report generated");
      }
    } catch {
      setError("Network error. Please check your connection and try again.");
    } finally {
      setLoading(false);
    }
  }
  • Finally, add the return statement with the updated JSX. Paste this directly below the handleScan function, still inside the component:
  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-3xl mx-auto space-y-8">
        <div className="text-center">
          <h1 className="text-3xl font-bold text-gray-900">API Security Auditor</h1>
        </div>
        <form onSubmit={handleScan} className="flex gap-3">
          <input type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://api.example.com" required className="flex-1 px-4 py-3 border border-gray-300 rounded-lg outline-none" />
          <button type="submit" disabled={loading} className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50">{loading ? "Scanning..." : "Scan"}</button>
        </form>
        {error && <div className="p-4 bg-red-50 border border-red-200 rounded-lg"><p className="text-red-800">{error}</p></div>}
        {result && <pre className="bg-white p-4 rounded-lg border text-sm overflow-auto">{JSON.stringify(result.report, null, 2)}</pre>}
      </div>
    </main>
  );
}

Why render as JSON for now?

Displaying the raw JSON lets you verify the AI pipeline is working end-to-end. In the next step, you'll replace this with a polished visual report card.

  • Save app/page.tsx.

Before you run the scan, what do you expect to see differently from the flat list in Step 3?

  • Return to your browser at http://localhost:3000.
  • Enter https://api.github.com in the URL field and click Scan.

After a few seconds, you should see a JSON object containing an overallScore (a number between 0 and 100), a riskLevel, a summary string, and a findings array where each entry now includes severity, explanation, impact, and recommendation fields.

Seeing an error instead of a report?

If you see "AI analysis unavailable", your ANTHROPIC_API_KEY in .env.local may be invalid or expired. Double-check you pasted the full key and that the dev server was restarted after creating the .env.local file.

If you see a network error, make sure the dev server is still running in your terminal.

Help me debug why my Claude API call is failing in the security auditor.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files: lib/schemas.ts, lib/analyze.ts, app/api/scan/route.ts, and app/page.tsx.

ⓧ I'd like to double check the full code

Scroll up and confirm each chunk is present in the correct file, in order:

  • lib/schemas.ts: The SecurityReportSchema and SecurityReport type are appended after the existing Finding type.
  • lib/analyze.ts: Contains imports, client initialization, SYSTEM_PROMPT constant, and the analyzeFindings function.
  • app/api/scan/route.ts: Has the analyzeFindings import at the top, and the try/catch block with fallback replacing the old return line.
  • app/page.tsx: Imports SecurityReport, defines ScanResult interface, uses result state, checks data.report in the handler, and renders with JSON.stringify.

Your scanner now produces an AI-powered security report with scores, severity ratings, and actionable fix recommendations. Next up, you'll transform this JSON blob into a polished visual report card.

Build the Visual Report Card UI

Your scanner now returns a fully structured security report from Claude, complete with scores, severity ratings, and fix recommendations. The problem is that this report arrives as a raw JSON blob dumped into a <pre> tag.

In this step, you will transform that structured data into a visual report card with color-coded score badges, risk levels, and expandable finding cards that make the analysis immediately readable.

In this step, get ready to:
  • Build a ReportCard component with a scored badge, risk level, and pass/fail stats.
  • Build a FindingsList component with expandable, severity-sorted finding cards.
  • Wire both components into the page with loading, error, and empty states.
Create the report card component

The report card is the top section of the audit display. It shows the numeric score inside a color-coded circle, the risk level as a badge, and a quick summary of how many checks passed versus failed.

  • Create a new folder called components in the root of your api-security-auditor project.
  • Create a new file called ReportCard.tsx inside components/ and add the following helper functions:
"use client";

import { SecurityReport } from "@/lib/schemas";

interface ReportCardProps {
  report: SecurityReport;
  scannedUrl: string;
  timestamp: string;
}

// Maps the numeric score to Tailwind color classes
function getScoreColor(score: number): string {
  if (score >= 80) return "text-green-600 bg-green-50 border-green-200";
  if (score >= 60) return "text-yellow-600 bg-yellow-50 border-yellow-200";
  if (score >= 40) return "text-orange-600 bg-orange-50 border-orange-200";
  return "text-red-600 bg-red-50 border-red-200";
}

// Maps the risk level string to badge colors
function getRiskBadgeColor(level: string): string {
  switch (level) {
    case "critical": return "bg-red-100 text-red-800";
    case "high": return "bg-orange-100 text-orange-800";
    case "medium": return "bg-yellow-100 text-yellow-800";
    case "low": return "bg-green-100 text-green-800";
    default: return "bg-gray-100 text-gray-800";
  }
}

What does this code do?

The getScoreColor function returns green classes for scores 80+, yellow for 60+, orange for 40+, and red for anything below 40. This maps directly to the risk thresholds Claude assigns.

The getRiskBadgeColor function handles the text badge next to the score, giving each risk level its own distinct color.

  • Below the helper functions in the same file, add the exported component:
export function ReportCard({ report, scannedUrl, timestamp }: ReportCardProps) {
  const failCount = report.findings.length;
  const passCount = report.passedChecks.length;

  return (
    <div className="space-y-6">
      <div className="flex items-center gap-6">
        <div className={`w-24 h-24 rounded-full flex items-center justify-center border-4 ${getScoreColor(report.overallScore)}`}>
          <span className="text-3xl font-bold">{report.overallScore}</span>
        </div>
        <div>
          <h2 className="text-xl font-semibold text-gray-900">Security Score</h2>
          <span className={`inline-block mt-1 px-3 py-1 rounded-full text-sm font-medium ${getRiskBadgeColor(report.riskLevel)}`}>
            {report.riskLevel.toUpperCase()} RISK
          </span>
          <p className="text-sm text-gray-500 mt-1">{scannedUrl} &middot; {new Date(timestamp).toLocaleString()}</p>
        </div>
      </div>
      <p className="text-gray-700">{report.summary}</p>
      <div className="flex gap-4">
        <div className="px-4 py-2 bg-green-50 rounded-lg">
          <span className="text-2xl font-bold text-green-700">{passCount}</span>
          <p className="text-sm text-green-600">Passed</p>
        </div>
        <div className="px-4 py-2 bg-red-50 rounded-lg">
          <span className="text-2xl font-bold text-red-700">{failCount}</span>
          <p className="text-sm text-red-600">Issues</p>
        </div>
      </div>
    </div>
  );
}

What does this code do?

The component renders a circular score badge using the w-24 h-24 rounded-full classes, colored dynamically by the score value. Next to it, the risk level shows as an uppercase badge.

The stat bar at the bottom gives a quick count of passed checks (green) versus issues found (red), so the learner can see the ratio at a glance.

  • Save components/ReportCard.tsx.
  • Confirm the file appears in your editor's file tree under components/.

File not appearing?

Make sure you created the components folder at the project root (same level as app/ and lib/), not inside app/.

help me find where to put my components folder

Create the findings list component

The findings list renders each security issue as an expandable card. Cards are sorted by severity so the most critical problems appear first.

  • Create a new file called FindingsList.tsx inside components/ and add the following setup code:
"use client";

import { useState } from "react";
import { SecurityReport } from "@/lib/schemas";

interface FindingsListProps {
  report: SecurityReport;
}

// Maps severity to colored badge classes
function getSeverityColor(severity: string): string {
  switch (severity) {
    case "critical": return "bg-red-100 text-red-800 border-red-200";
    case "high": return "bg-orange-100 text-orange-800 border-orange-200";
    case "medium": return "bg-yellow-100 text-yellow-800 border-yellow-200";
    case "low": return "bg-blue-100 text-blue-800 border-blue-200";
    case "info": return "bg-gray-100 text-gray-800 border-gray-200";
    default: return "bg-gray-100 text-gray-800 border-gray-200";
  }
}

// Defines sort priority so critical appears first
const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"];

What does this code do?

The getSeverityColor function assigns distinct badge colors per severity level so the learner can visually scan for critical issues.

The SEVERITY_ORDER array defines the sort key. When findings are sorted by index position in this array, critical issues float to the top.

  • Below the constants in the same file, add the exported component:
export function FindingsList({ report }: FindingsListProps) {
  const [expanded, setExpanded] = useState<Set<string>>(new Set());

  const sortedFindings = [...report.findings].sort(
    (a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
  );

  function toggleExpanded(checkId: string) {
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(checkId)) { next.delete(checkId); } else { next.add(checkId); }
      return next;
    });
  }

  return (
    <div className="space-y-3">
      <h3 className="text-lg font-semibold text-gray-900">Findings</h3>
      {sortedFindings.map((finding) => (
        <div key={finding.checkId} className="border rounded-lg overflow-hidden">
          <button onClick={() => toggleExpanded(finding.checkId)} className="w-full px-4 py-3 flex items-center gap-3 text-left hover:bg-gray-50">
            <span className={`px-2 py-0.5 text-xs font-medium rounded border ${getSeverityColor(finding.severity)}`}>{finding.severity.toUpperCase()}</span>
            <span className="font-medium text-gray-900 flex-1">{finding.title}</span>
            <span className="text-gray-400">{expanded.has(finding.checkId) ? "\u25B2" : "\u25BC"}</span>
          </button>
          {expanded.has(finding.checkId) && (
            <div className="px-4 pb-4 space-y-3 border-t bg-gray-50">
              <div><p className="text-sm font-medium text-gray-700 mt-3">Explanation</p><p className="text-sm text-gray-600">{finding.explanation}</p></div>
              <div><p className="text-sm font-medium text-gray-700">Impact</p><p className="text-sm text-gray-600">{finding.impact}</p></div>
              <div><p className="text-sm font-medium text-gray-700">Evidence</p><code className="block text-xs bg-white p-2 rounded border text-gray-800">{finding.evidence}</code></div>
              <div><p className="text-sm font-medium text-gray-700">Recommendation</p><p className="text-sm text-gray-600">{finding.recommendation}</p></div>
            </div>
          )}
        </div>
      ))}
    </div>
  );
}

What does this code do?

A Set tracks which finding cards are currently expanded. Clicking a card toggles its checkId in and out of the set.

Each card shows a severity badge and title in its collapsed state. When expanded, it reveals the full explanation, impact, raw evidence header value, and a recommended fix.

  • Save components/FindingsList.tsx.
  • Check your terminal. The dev server should still be running with no compilation errors since neither component is imported yet.

Seeing a TypeScript error?

If your terminal shows a type error referencing SecurityReport, double check that your import path is @/lib/schemas (not a relative path like ../lib/schemas).

help me fix my FindingsList import error

Wire the components into the page

Both components are ready. Now you need to import them into app/page.tsx and replace the raw JSON dump with the visual report card, plus add proper loading and empty states.

  • In app/page.tsx, find the imports at the top of the file:
"use client";

import { useState } from "react";
import { SecurityReport } from "@/lib/schemas";
  • Replace those import lines with the following to add the two new component imports:
"use client";

import { useState } from "react";
import { ReportCard } from "@/components/ReportCard";
import { FindingsList } from "@/components/FindingsList";
import { SecurityReport } from "@/lib/schemas";
  • Now find the return statement inside the Home component. Replace the entire return (...) block with the following:
  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-3xl mx-auto space-y-8">
        <div className="text-center">
          <h1 className="text-3xl font-bold text-gray-900">API Security Auditor</h1>
          <p className="mt-2 text-gray-600">Paste a public API endpoint to scan its security headers and get AI-powered recommendations.</p>
        </div>
        <form onSubmit={handleScan} className="flex gap-3">
          <input type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://api.example.com/v1/health" required className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
          <button type="submit" disabled={loading} className="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">{loading ? "Scanning..." : "Scan"}</button>
        </form>
        {error && <div className="p-4 bg-red-50 border border-red-200 rounded-lg"><p className="text-red-800">{error}</p></div>}
        {loading && (
          <div className="space-y-4 animate-pulse">
            <div className="h-24 bg-gray-200 rounded-lg" />
            <div className="h-8 bg-gray-200 rounded w-3/4" />
            <div className="h-32 bg-gray-200 rounded-lg" />
          </div>
        )}
        {result && (
          <div className="space-y-8 bg-white p-6 rounded-xl shadow-sm border">
            <ReportCard report={result.report} scannedUrl={result.scannedUrl} timestamp={result.timestamp} />
            <FindingsList report={result.report} />
            <p className="text-xs text-gray-400 text-center">This is an informational scan, not a formal security certification or penetration test.</p>
          </div>
        )}
        {!loading && !result && !error && (
          <div className="text-center py-12 text-gray-400">
            <p>Enter an API endpoint URL above to begin scanning.</p>
          </div>
        )}
      </div>
    </main>
  );

What changed in the page?

The skeleton loading state (animate-pulse blocks) appears while Claude is analyzing. It gives visual feedback that something is happening during the 5-10 second wait.

The <ReportCard> and <FindingsList> components replace the raw JSON. The disclaimer at the bottom reminds users this is informational, not a formal audit.

The empty state handles the case where no scan has been run yet, so the page is never completely blank.

  • Save app/page.tsx.
  • Switch to your browser at http://localhost:3000. You should see the subtitle text "Paste a public API endpoint to scan its security headers and get AI-powered recommendations." below the heading, with the empty state message below the form.

Before you run a scan, what do you expect the report card to show for a well-known public API like GitHub's?

  • Enter https://api.github.com in the URL field and click Scan.

You should see a polished report card with a colored score badge (the exact color depends on GitHub's current headers), a risk level label, the endpoint URL with timestamp, a summary paragraph, pass/fail stat counts, and a list of expandable finding cards sorted by severity.

  • Click on any finding card to expand it. You should see the explanation, impact, evidence (the raw header value), and a recommended fix.

Seeing a blank page or error?

Check your terminal for compilation errors. A common cause is a typo in the import path. Both component imports must use @/components/ReportCard and @/components/FindingsList (capital R and F).

If the scan button works but you see "AI analysis unavailable", your ANTHROPIC_API_KEY in .env.local may be invalid. Restart the dev server after updating it.

help me debug my report card not rendering

✔️ Awesome, I've got everything!

Great. Double check that expanding a finding card shows all four sections: Explanation, Impact, Evidence, and Recommendation.

ⓧ I'd like to double check the full code

Here are the three files you created or updated in this step. Scroll through and confirm each one matches yours.

components/ReportCard.tsx:

"use client";

import { SecurityReport } from "@/lib/schemas";

interface ReportCardProps {
  report: SecurityReport;
  scannedUrl: string;
  timestamp: string;
}

function getScoreColor(score: number): string {
  if (score >= 80) return "text-green-600 bg-green-50 border-green-200";
  if (score >= 60) return "text-yellow-600 bg-yellow-50 border-yellow-200";
  if (score >= 40) return "text-orange-600 bg-orange-50 border-orange-200";
  return "text-red-600 bg-red-50 border-red-200";
}

function getRiskBadgeColor(level: string): string {
  switch (level) {
    case "critical": return "bg-red-100 text-red-800";
    case "high": return "bg-orange-100 text-orange-800";
    case "medium": return "bg-yellow-100 text-yellow-800";
    case "low": return "bg-green-100 text-green-800";
    default: return "bg-gray-100 text-gray-800";
  }
}
export function ReportCard({ report, scannedUrl, timestamp }: ReportCardProps) {
  const failCount = report.findings.length;
  const passCount = report.passedChecks.length;

  return (
    <div className="space-y-6">
      <div className="flex items-center gap-6">
        <div className={`w-24 h-24 rounded-full flex items-center justify-center border-4 ${getScoreColor(report.overallScore)}`}>
          <span className="text-3xl font-bold">{report.overallScore}</span>
        </div>
        <div>
          <h2 className="text-xl font-semibold text-gray-900">Security Score</h2>
          <span className={`inline-block mt-1 px-3 py-1 rounded-full text-sm font-medium ${getRiskBadgeColor(report.riskLevel)}`}>
            {report.riskLevel.toUpperCase()} RISK
          </span>
          <p className="text-sm text-gray-500 mt-1">{scannedUrl} &middot; {new Date(timestamp).toLocaleString()}</p>
        </div>
      </div>
      <p className="text-gray-700">{report.summary}</p>
      <div className="flex gap-4">
        <div className="px-4 py-2 bg-green-50 rounded-lg">
          <span className="text-2xl font-bold text-green-700">{passCount}</span>
          <p className="text-sm text-green-600">Passed</p>
        </div>
        <div className="px-4 py-2 bg-red-50 rounded-lg">
          <span className="text-2xl font-bold text-red-700">{failCount}</span>
          <p className="text-sm text-red-600">Issues</p>
        </div>
      </div>
    </div>
  );
}

components/FindingsList.tsx:

"use client";

import { useState } from "react";
import { SecurityReport } from "@/lib/schemas";

interface FindingsListProps {
  report: SecurityReport;
}

function getSeverityColor(severity: string): string {
  switch (severity) {
    case "critical": return "bg-red-100 text-red-800 border-red-200";
    case "high": return "bg-orange-100 text-orange-800 border-orange-200";
    case "medium": return "bg-yellow-100 text-yellow-800 border-yellow-200";
    case "low": return "bg-blue-100 text-blue-800 border-blue-200";
    case "info": return "bg-gray-100 text-gray-800 border-gray-200";
    default: return "bg-gray-100 text-gray-800 border-gray-200";
  }
}

const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"];
export function FindingsList({ report }: FindingsListProps) {
  const [expanded, setExpanded] = useState<Set<string>>(new Set());

  const sortedFindings = [...report.findings].sort(
    (a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
  );

  function toggleExpanded(checkId: string) {
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(checkId)) { next.delete(checkId); } else { next.add(checkId); }
      return next;
    });
  }

  return (
    <div className="space-y-3">
      <h3 className="text-lg font-semibold text-gray-900">Findings</h3>
      {sortedFindings.map((finding) => (
        <div key={finding.checkId} className="border rounded-lg overflow-hidden">
          <button onClick={() => toggleExpanded(finding.checkId)} className="w-full px-4 py-3 flex items-center gap-3 text-left hover:bg-gray-50">
            <span className={`px-2 py-0.5 text-xs font-medium rounded border ${getSeverityColor(finding.severity)}`}>{finding.severity.toUpperCase()}</span>
            <span className="font-medium text-gray-900 flex-1">{finding.title}</span>
            <span className="text-gray-400">{expanded.has(finding.checkId) ? "\u25B2" : "\u25BC"}</span>
          </button>
          {expanded.has(finding.checkId) && (
            <div className="px-4 pb-4 space-y-3 border-t bg-gray-50">
              <div><p className="text-sm font-medium text-gray-700 mt-3">Explanation</p><p className="text-sm text-gray-600">{finding.explanation}</p></div>
              <div><p className="text-sm font-medium text-gray-700">Impact</p><p className="text-sm text-gray-600">{finding.impact}</p></div>
              <div><p className="text-sm font-medium text-gray-700">Evidence</p><code className="block text-xs bg-white p-2 rounded border text-gray-800">{finding.evidence}</code></div>
              <div><p className="text-sm font-medium text-gray-700">Recommendation</p><p className="text-sm text-gray-600">{finding.recommendation}</p></div>
            </div>
          )}
        </div>
      ))}
    </div>
  );
}

app/page.tsx:

"use client";

import { useState } from "react";
import { ReportCard } from "@/components/ReportCard";
import { FindingsList } from "@/components/FindingsList";
import { SecurityReport } from "@/lib/schemas";

interface ScanResult {
  report: SecurityReport;
  scannedUrl: string;
  timestamp: string;
}

export default function Home() {
  const [url, setUrl] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [result, setResult] = useState<ScanResult | null>(null);
  async function handleScan(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    setResult(null);
    setLoading(true);
    try {
      const response = await fetch("/api/scan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }) });
      const data = await response.json();
      if (!response.ok) { setError(data.error || "Scan failed"); return; }
      if (data.report) {
        setResult({ report: data.report, scannedUrl: data.scannedUrl, timestamp: data.timestamp });
      } else {
        setError(data.error || "No report generated");
      }
    } catch {
      setError("Network error. Please check your connection and try again.");
    } finally {
      setLoading(false);
    }
  }
  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-3xl mx-auto space-y-8">
        <div className="text-center">
          <h1 className="text-3xl font-bold text-gray-900">API Security Auditor</h1>
          <p className="mt-2 text-gray-600">Paste a public API endpoint to scan its security headers and get AI-powered recommendations.</p>
        </div>
        <form onSubmit={handleScan} className="flex gap-3">
          <input type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://api.example.com/v1/health" required className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
          <button type="submit" disabled={loading} className="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">{loading ? "Scanning..." : "Scan"}</button>
        </form>
        {error && <div className="p-4 bg-red-50 border border-red-200 rounded-lg"><p className="text-red-800">{error}</p></div>}
        {loading && (
          <div className="space-y-4 animate-pulse">
            <div className="h-24 bg-gray-200 rounded-lg" />
            <div className="h-8 bg-gray-200 rounded w-3/4" />
            <div className="h-32 bg-gray-200 rounded-lg" />
          </div>
        )}
        {result && (
          <div className="space-y-8 bg-white p-6 rounded-xl shadow-sm border">
            <ReportCard report={result.report} scannedUrl={result.scannedUrl} timestamp={result.timestamp} />
            <FindingsList report={result.report} />
            <p className="text-xs text-gray-400 text-center">This is an informational scan, not a formal security certification or penetration test.</p>
          </div>
        )}
        {!loading && !result && !error && (
          <div className="text-center py-12 text-gray-400">
            <p>Enter an API endpoint URL above to begin scanning.</p>
          </div>
        )}
      </div>
    </main>
  );
}

Your security auditor now produces a polished, readable report from raw HTTP headers. Next up, the Secret Mission challenges you to add scan comparison so you can track whether security issues get fixed over time.

Secret mission

Scan Comparison and Drift Detection

A single security scan is just a snapshot. Real security teams compare scans over time to track whether fixes landed and catch new regressions. Can you add localStorage persistence and diff logic so the auditor highlights resolved, new, and unchanged findings on re-scans?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing cloud costs. The Anthropic API is pay-per-use and only charges when you actively run a scan.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The api-security-auditor project folder on your Desktop (includes all source code, node_modules, and .env.local).
  • Browser localStorage data storing previous scan reports (from the Secret Mission).

Keep everything running

No action needed. Choose this if you are still actively building or want to run more scans.

  • Your project folder stays on your Desktop, ready to use.
  • Start the dev server again anytime with npm run dev from the api-security-auditor folder.
  • Your Anthropic API key remains in .env.local. Each scan costs approximately $0.05 in API usage.
  • Previous scan results remain in your browser's localStorage for comparison features.

Pause - I'll come back to this later

Stop the dev server to free up your terminal. Your files and scan history stay intact for when you return.

  • Press Ctrl+C in the terminal where npm run dev is running to stop the dev server.
  • Your project folder, .env.local, and localStorage data all remain untouched.
  • No charges accrue while the server is stopped. You only pay when you actively run a scan.

Delete - I don't want to use this again

Remove all project files and scan history. This cannot be undone.

  • Press Ctrl+C in the terminal to stop the dev server if it is still running.
  • Delete the project folder by running the command below for your operating system.

macOS

cd ~/Desktop
rm -rf api-security-auditor

Windows

cd ~\Desktop
Remove-Item -Recurse -Force api-security-auditor
  • Clear your scan history from localStorage by opening your browser's Developer Tools, navigating to the Application tab, selecting Local Storage, and deleting any entries that start with audit_.
  • Your Anthropic API key still exists in your Anthropic account. You can keep it for future projects or revoke it from the API Keys page in the Anthropic Console.

Nice Work!

Nice Work!

You just built a fully functional API security auditor that combines deterministic header checks with AI-powered analysis to produce actionable security reports.

You've learned how to:

  • Build a Next.js Route Handler that safely fetches external URLs with SSRF protection, timeouts, and redirect validation to prevent server-side request forgery.
  • Integrate Claude's structured outputs with Zod validation to guarantee schema-conformant AI analysis from raw security findings.
  • Create a visual report card that displays scored, prioritized security findings with severity ratings, explanations, and fix recommendations.
  • Secret Mission: Added scan comparison to track resolved, new, and unchanged findings over time using localStorage persistence.

Ready to quiz yourself?