Skip to content
AI Full-Stack Wiki

Tutorial: Build a Secure Streaming API with Sessions, API Keys, and SSE

Build a Next.js API that signs users in with hashed passwords and signed cookies, issues hashed API keys, rate-limits callers, and streams Server-Sent Events.

Difficulty: Intermediate25 min readUpdated
On this page· What you will build

Why this matters

The endpoint that calls a model is the most expensive door in your app, and bots find unprotected ones within days. This tutorial builds that door properly, following the APIs and authentication article: a JSON API where browsers sign in with a session cookie, other programs use API keys, every caller is rate-limited, and the reply streams as Server-Sent Events. You will test every rejection path with curl before writing a single line of UI, which is how APIs should be built. Tutorial 6 then ships this exact app.

What you will build#

EndpointAuthWhat it does
POST /api/loginnoneChecks email and password, sets a signed session cookie
POST /api/logoutnoneClears the cookie
GET /api/mecookie or keySays who is calling, and how
POST /api/keyscookie onlyCreates an API key, shown once
POST /api/generatecookie or keyValidates, rate-limits, and streams a reply as SSE
Secure streaming API request pipelineEvery request to the generate endpoint passes four gates in order: authenticate from a session cookie or an API key, check the caller's rate limit, validate the body, and only then stream the reply as Server-Sent Events.callerBrowserScriptauthenticate()cookie or keyRate limitper userValidateZodModel streamSSE + heartbeat

Step 1 of 4

Authenticate

Read the API key from the Authorization header, or verify the signed session cookie. No valid identity means 401.

No credentials

HTTP 401
{ "error": "Not signed in" }
Secure streaming API: each request is authenticated from a session cookie or an API key, rate-limited per user, and validated before the model reply is streamed as Server-Sent Events.
Show text description

The order matters: the cheap checks that reject strangers and floods run before anything that costs money.

  • Browser: Sends the HttpOnly session cookie automatically.
  • Script: Another program, authenticated with an API key in the Authorization header.
  • authenticate(): Verifies the HMAC-signed session or looks up the SHA-256 of the API key. Identity never comes from the body.
  • Rate limit: Fixed window per user, checked before any expensive work; 429 with Retry-After when exceeded.
  • Validate: Rejects bad input with 400 and a list of issues, in the same error shape as every other endpoint.
  • Model stream: Streams start, token, and done events with ids, sends heartbeats while waiting, and stops when the client disconnects.
  1. Authenticate. Read the API key from the Authorization header, or verify the signed session cookie. No valid identity means 401.
  2. Rate limit. Count this caller's requests in the current window. Over the limit means 429, with Retry-After saying when to come back.
  3. Validate. Parse the body with Zod. Invalid input means 400 with every issue listed.
  4. Stream. Only now call the model. Events carry ids, heartbeats keep the connection open, and a disconnect stops generation.

You need Node.js 22.18 or later and curl. Read APIs for AI apps first. As in the other tutorials, a stand-in streams the "model" reply, so no API key is needed.

1. Create an API-only app#

  1. Generate the project

    npx create-next-app@latest secure-api --api --yes
    cd secure-api
    npm install zod
    npm pkg set type=module
    rm -r app/route.ts "app/[slug]"

    --api creates a project with only Route Handlers: no pages, no React components, no CSS. The last command removes its two example routes. "type": "module" lets the Node.js client script in step 8 use import without a warning.

  2. Set the session secret

    node -e "console.log('SESSION_SECRET=' + require('crypto').randomBytes(32).toString('base64url'))" > .env.local

    The secret signs session cookies. Anyone who knows it can forge a session for any user, so it is random, long, and never committed (.env* is already in .gitignore).

  3. Allow .ts imports in scripts

    Add "allowImportingTsExtensions": true to compilerOptions in tsconfig.json. Next.js code imports without extensions; the client script runs directly in Node.js, which needs them.

2. Passwords and users#

Passwords are never stored, only a slow, salted hash. scrypt is built into Node.js and is designed to make each guess expensive for an attacker holding a leaked hash.

lib/passwords.ts
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
 
/** scrypt is deliberately slow and memory-hungry, which makes guessing passwords from a leaked hash expensive. */
function derive(password: string, salt: Buffer): Promise<Buffer> {
  return new Promise((resolve, reject) => scrypt(password, salt, 64, (error, key) => (error ? reject(error) : resolve(key))));
}
 
export async function hashPassword(password: string): Promise<string> {
  const salt = randomBytes(16); // a unique salt per password defeats precomputed tables
  return `${salt.toString("base64url")}.${(await derive(password, salt)).toString("base64url")}`;
}
 
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
  const [salt, hash] = stored.split(".");
  if (!salt || !hash) return false;
  const expected = Buffer.from(hash, "base64url");
  const actual = await derive(password, Buffer.from(salt, "base64url"));
  return actual.length === expected.length && timingSafeEqual(actual, expected);
}

The user store is in memory with one demo account, standing in for a users table:

lib/users.ts
import { hashPassword } from "./passwords";
 
export interface User {
  id: string;
  email: string;
  passwordHash: string;
}
 
// In-memory stand-in for a users table, with one demo account. Hashing is async, so it starts once
// at module load and every lookup waits for it.
const ready: Promise<Map<string, User>> = hashPassword("correct-horse-battery-staple").then(
  (passwordHash) => new Map([["ada@example.com", { id: "user_ada", email: "ada@example.com", passwordHash }]]),
);
 
export async function findUserByEmail(email: string): Promise<User | undefined> {
  return (await ready).get(email.toLowerCase());
}

3. Sessions and API keys#

The session helper is the one from the article, with two small additions: a clearSession for logout, and an optional now parameter so Tutorial 6 can test expiry without waiting a week.

lib/session.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
 
const COOKIE = "session";
const MAX_AGE = 60 * 60 * 24 * 7; // 7 days
 
function secret(): string {
  const value = process.env.SESSION_SECRET;
  if (!value || value.length < 32) throw new Error("SESSION_SECRET must be set to at least 32 random characters");
  return value;
}
 
const sign = (payload: string) => createHmac("sha256", secret()).update(payload).digest("base64url");
 
export function createSessionToken(userId: string, now = Date.now()): string {
  const payload = Buffer.from(JSON.stringify({ sub: userId, exp: Math.floor(now / 1000) + MAX_AGE })).toString("base64url");
  return `${payload}.${sign(payload)}`;
}
 
export function verifySessionToken(token: string | undefined, now = Date.now()): string | null {
  if (!token) return null;
  const [payload, signature] = token.split(".");
  if (!payload || !signature) return null;
  const expected = Buffer.from(sign(payload));
  const actual = Buffer.from(signature);
  // Constant-time comparison, so timing does not reveal how much of the signature matched.
  if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
  const { sub, exp } = JSON.parse(Buffer.from(payload, "base64url").toString()) as { sub: string; exp: number };
  return exp > now / 1000 ? sub : null;
}
 
export async function setSession(userId: string) {
  (await cookies()).set(COOKIE, createSessionToken(userId), {
    httpOnly: true, // JavaScript in the page cannot read it
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax", // not sent on cross-site POSTs, which blocks most CSRF
    path: "/",
    maxAge: MAX_AGE,
  });
}
 
export async function clearSession() {
  (await cookies()).delete(COOKIE);
}
 
export async function getSessionUserId(): Promise<string | null> {
  return verifySessionToken((await cookies()).get(COOKIE)?.value);
}

API keys are for programs. The key is random, shown once, and only its SHA-256 hash is kept:

lib/api-keys.ts
import { createHash, randomBytes } from "node:crypto";
 
interface ApiKeyRecord {
  id: string;
  userId: string;
  createdAt: Date;
  lastUsedAt: Date | null;
}
 
// Keyed by the SHA-256 of the key. The key itself is never stored, so a leak of this table
// (in a real app, a database table) exposes no working keys.
const keys = new Map<string, ApiKeyRecord>();
 
const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");
 
/** Create a key for a user. The plain key is returned exactly once; show it, then forget it. */
export function createApiKey(userId: string): { id: string; key: string } {
  const key = `sk_${randomBytes(32).toString("base64url")}`;
  const record = { id: `key_${randomBytes(6).toString("hex")}`, userId, createdAt: new Date(), lastUsedAt: null };
  keys.set(sha256(key), record);
  return { id: record.id, key };
}
 
export function verifyApiKey(key: string): ApiKeyRecord | null {
  const record = keys.get(sha256(key));
  if (!record) return null;
  record.lastUsedAt = new Date();
  return record;
}

Why SHA-256 here but slow scrypt for passwords? Passwords are chosen by people and are guessable, so each guess must be expensive. An API key is 32 random bytes; there is nothing to guess, and a fast hash keeps every request cheap. Looking a key up by its hash also means there is no string comparison to time.

One function decides who is calling, for every route:

lib/auth.ts
import { verifyApiKey } from "./api-keys";
import { getSessionUserId } from "./session";
 
export type Caller = { userId: string; via: "session" } | { userId: string; via: "api-key"; keyId: string };
 
/** Who is calling: a program with an API key, or a browser with a session cookie. Never the request body. */
export async function authenticate(request: Request): Promise<Caller | null> {
  const authorization = request.headers.get("authorization");
  if (authorization) {
    // A presented key that is wrong is a failure; do not fall back to the cookie.
    const record = authorization.startsWith("Bearer ") ? verifyApiKey(authorization.slice(7)) : null;
    return record ? { userId: record.userId, via: "api-key", keyId: record.id } : null;
  }
  const userId = await getSessionUserId();
  return userId ? { userId, via: "session" } : null;
}

Caller is a discriminated union, so routes can tell the two kinds apart safely, and only API-key callers have a keyId.

4. Rate limits and errors#

A fixed-window limiter, kept in memory for this single-process app:

lib/rate-limit.ts
const windows = new Map<string, { window: number; count: number }>();
 
/**
 * Fixed-window limiter: at most `limit` calls per `windowSeconds` for this key.
 * In memory, so it only counts calls to this one process. With more than one instance,
 * use the Redis version from the semantic notes tutorial instead.
 */
export function rateLimit(key: string, limit: number, windowSeconds: number, now = Date.now()) {
  const window = Math.floor(now / 1000 / windowSeconds);
  const entry = windows.get(key);
  const count = entry?.window === window ? entry.count + 1 : 1;
  windows.set(key, { window, count });
  const retryAfter = (window + 1) * windowSeconds - Math.floor(now / 1000);
  return { allowed: count <= limit, remaining: Math.max(0, limit - count), retryAfter };
}

And one error shape for every endpoint, so clients handle errors in one place:

lib/http.ts
import type { z } from "zod";
 
/** One error shape for every endpoint: { error, issues? }. */
export function apiError(status: number, error: string, init: { headers?: HeadersInit; issues?: z.ZodError["issues"] } = {}) {
  const issues = init.issues?.map((i) => ({ path: i.path.map(String).join("."), message: i.message }));
  return Response.json(issues ? { error, issues } : { error }, { status, headers: init.headers });
}

5. Sign-in routes#

app/api/login/route.ts
import { z } from "zod";
import { apiError } from "@/lib/http";
import { hashPassword, verifyPassword } from "@/lib/passwords";
import { rateLimit } from "@/lib/rate-limit";
import { setSession } from "@/lib/session";
import { findUserByEmail } from "@/lib/users";
 
const Credentials = z.object({ email: z.email(), password: z.string().min(1).max(200) });
 
// A real hash of a random throwaway password: checking against it takes as long as checking a real one.
const dummyHash = hashPassword(crypto.randomUUID());
 
export async function POST(request: Request) {
  const parsed = Credentials.safeParse(await request.json().catch(() => null));
  if (!parsed.success) return apiError(400, "Invalid request", { issues: parsed.error.issues });
  const { email, password } = parsed.data;
 
  // Slow down password guessing for each account.
  const limit = rateLimit(`login:${email.toLowerCase()}`, 5, 60);
  if (!limit.allowed) return apiError(429, "Too many attempts. Try again later.", { headers: { "Retry-After": String(limit.retryAfter) } });
 
  const user = await findUserByEmail(email);
  // Always run the slow comparison, so response time does not reveal whether the email exists.
  const valid = await verifyPassword(password, user?.passwordHash ?? (await dummyHash));
  if (!user || !valid) return apiError(401, "Wrong email or password");
 
  await setSession(user.id);
  return Response.json({ userId: user.id });
}

Three protections hide in those lines. The error message is the same for an unknown email and a wrong password. The dummy hash makes both cases take the same time, because scrypt runs either way. And the per-email rate limit caps guessing at five attempts a minute.

app/api/logout/route.ts
import { clearSession } from "@/lib/session";
 
export async function POST() {
  await clearSession();
  return new Response(null, { status: 204 });
}
app/api/me/route.ts
import { authenticate } from "@/lib/auth";
import { apiError } from "@/lib/http";
 
export async function GET(request: Request) {
  const caller = await authenticate(request);
  if (!caller) return apiError(401, "Not signed in");
  return Response.json(caller);
}
app/api/keys/route.ts
import { createApiKey } from "@/lib/api-keys";
import { authenticate } from "@/lib/auth";
import { apiError } from "@/lib/http";
 
export async function POST(request: Request) {
  const caller = await authenticate(request);
  if (!caller) return apiError(401, "Not signed in");
  // Keys can be created from a signed-in session only, so a leaked key cannot mint more keys.
  if (caller.via !== "session") return apiError(403, "Create API keys from a signed-in session");
 
  const { id, key } = createApiKey(caller.userId);
  return Response.json({ id, key, note: "Store this key now. It will not be shown again." }, { status: 201 });
}

Note the status codes: 401 when we do not know who you are, 403 when we know and the answer is no.

6. The streaming endpoint#

First the SSE helpers. sseEvent now accepts an optional id, and a heartbeat comment keeps idle connections alive:

lib/sse.ts
/** Format one Server-Sent Event. Data is JSON-encoded, so it never contains a raw newline. */
export function sseEvent(event: string, data: unknown, id?: string | number): string {
  return `${id === undefined ? "" : `id: ${id}\n`}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
 
/** A comment line. Clients ignore it; proxies see traffic and keep the connection open. */
export const SSE_HEARTBEAT = ": ping\n\n";
 
export const SSE_HEADERS = {
  "Content-Type": "text/event-stream; charset=utf-8",
  "Cache-Control": "no-cache, no-transform",
  "X-Accel-Buffering": "no", // ask nginx-style proxies not to buffer the stream
} as const;

The stand-in model waits 2.5 seconds before its first word, like a model that is thinking, which is exactly when heartbeats matter:

lib/fake-model.ts
import { setTimeout as sleep } from "node:timers/promises";
 
/**
 * Stand-in for a streaming model call. It pauses before answering, like a model that is thinking,
 * then yields the reply word by word. Later in the wiki this becomes anthropic.messages.stream().
 */
export async function* fakeModel(prompt: string, signal: AbortSignal): AsyncGenerator<string> {
  await sleep(2_500, undefined, { signal });
  const reply = `(Simulated reply.) You wrote ${prompt.split(/\s+/).length} words. A real model would answer: "${prompt}"`;
  for (const word of reply.split(" ")) {
    await sleep(50, undefined, { signal });
    yield `${word} `;
  }
}

The route checks the cheap things first and only then starts the expensive part:

app/api/generate/route.ts
import { z } from "zod";
import { authenticate } from "@/lib/auth";
import { fakeModel } from "@/lib/fake-model";
import { apiError } from "@/lib/http";
import { rateLimit } from "@/lib/rate-limit";
import { SSE_HEADERS, SSE_HEARTBEAT, sseEvent } from "@/lib/sse";
 
const Body = z.object({ prompt: z.string().trim().min(1).max(4_000) });
 
export async function POST(request: Request) {
  // 1. Who is calling? Every route that costs money starts here.
  const caller = await authenticate(request);
  if (!caller) return apiError(401, "Not signed in");
 
  // 2. Are they within their limit? Checked before any expensive work.
  const limit = rateLimit(`generate:${caller.userId}`, 10, 60);
  if (!limit.allowed) {
    return apiError(429, "Rate limit reached. Try again shortly.", { headers: { "Retry-After": String(limit.retryAfter) } });
  }
 
  // 3. Is the input valid?
  const parsed = Body.safeParse(await request.json().catch(() => null));
  if (!parsed.success) return apiError(400, "Invalid request", { issues: parsed.error.issues });
 
  // 4. Stream the reply as Server-Sent Events.
  const encoder = new TextEncoder();
  const requestId = crypto.randomUUID();
 
  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      const send = (chunk: string) => controller.enqueue(encoder.encode(chunk));
      // Models can pause for seconds before the first token; heartbeats keep proxies from closing the connection.
      const heartbeat = setInterval(() => send(SSE_HEARTBEAT), 1_000);
      let n = 0;
      try {
        send(sseEvent("start", { requestId }, n++));
        for await (const text of fakeModel(parsed.data.prompt, request.signal)) send(sseEvent("token", { text }, n++));
        send(sseEvent("done", { tokens: n - 1 }, n++));
      } catch (error) {
        if (!request.signal.aborted) {
          console.error("generation failed", { requestId, error });
          send(sseEvent("error", { message: "Generation failed. Please retry." }));
        }
      } finally {
        clearInterval(heartbeat);
        try {
          controller.close();
        } catch {
          // already closed because the client disconnected
        }
      }
    },
  });
 
  return new Response(stream, { headers: SSE_HEADERS });
}

Once the stream has started, the HTTP status is already 200 and cannot change. That is why a failure during generation is reported as an error event, not as a status code, and why clients must handle both.

7. Test every door with curl#

Start the dev server with npm run dev. In a second terminal, try to get in without credentials first:

curl -s http://localhost:3000/api/me
# {"error":"Not signed in"}
 
curl -s -X POST -H "Content-Type: application/json" -d '{"prompt":"hi"}' http://localhost:3000/api/generate
# {"error":"Not signed in"}
 
curl -s -X POST -H "Content-Type: application/json" -d '{"email":"not-an-email"}' http://localhost:3000/api/login
# {"error":"Invalid request","issues":[{"path":"email","message":"Invalid email address"},
#  {"path":"password","message":"Invalid input: expected string, received undefined"}]}

Sign in, saving the cookie to a file the way a browser would keep it:

curl -s -c cookies.txt -X POST -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","password":"correct-horse-battery-staple"}' \
  http://localhost:3000/api/login
# {"userId":"user_ada"}
 
curl -s -b cookies.txt http://localhost:3000/api/me
# {"userId":"user_ada","via":"session"}

Now stream a reply. -N turns off curl's buffering so you see events as they arrive:

curl -N -b cookies.txt -X POST -H "Content-Type: application/json" \
  -d '{"prompt":"Explain SSE briefly"}' http://localhost:3000/api/generate
id: 0
event: start
data: {"requestId":"eea9b706-f64b-4376-9047-cc339882f015"}
 
: ping
 
: ping
 
id: 1
event: token
data: {"text":"(Simulated "}
 
id: 2
event: token
data: {"text":"reply.) "}
...

The two : ping lines arrive while the model "thinks"; without them, some proxies close a silent connection after a few seconds. Press Ctrl+C mid-stream: request.signal aborts, the generator's sleep rejects, and generation stops on the server.

Create an API key, then use it:

curl -s -b cookies.txt -X POST http://localhost:3000/api/keys
# {"id":"key_c9c7d84eda41","key":"sk_cVAQx4yur...","note":"Store this key now. It will not be shown again."}
 
KEY=sk_...   # paste your key
curl -s -H "Authorization: Bearer $KEY" http://localhost:3000/api/me
# {"userId":"user_ada","via":"api-key","keyId":"key_c9c7d84eda41"}
 
curl -s -X POST -H "Authorization: Bearer $KEY" http://localhost:3000/api/keys
# {"error":"Create API keys from a signed-in session"}     (403)
 
curl -s -b cookies.txt -H "Authorization: Bearer sk_wrong" http://localhost:3000/api/me
# {"error":"Not signed in"}     (a bad key does not fall back to the cookie)

Finally, the limits. Eleven quick generate calls (--max-time 0.3 hangs up right after the headers):

for i in $(seq 1 11); do
  curl -s -o /dev/null -w "%{http_code} " --max-time 0.3 -b cookies.txt -X POST \
    -H "Content-Type: application/json" -d '{"prompt":"x"}' http://localhost:3000/api/generate
done
# 200 200 200 200 200 200 200 200 200 200 429

The 429 response carries Retry-After: 43 (seconds until the window resets), so a well-behaved client knows exactly when to retry. Six wrong passwords for one account give 401 401 401 401 401 429.

8. A Node.js client#

Browsers handle cookies for you; programs do not. This client signs in, creates a key, then switches to key-only authentication and parses the stream:

lib/read-sse.ts
export interface SSEMessage {
  event: string;
  data: string;
  id?: string;
}
 
/** Parse a text/event-stream response body into events (handles chunks split mid-event). */
export async function* readSSE(response: Response): AsyncGenerator<SSEMessage> {
  if (!response.ok || !response.body) throw new Error(`Stream failed with HTTP ${response.status}`);
  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
 
  while (true) {
    const { value, done } = await reader.read();
    if (done) return;
    buffer += value.replace(/\r\n/g, "\n");
 
    let boundary = buffer.indexOf("\n\n");
    while (boundary !== -1) {
      const raw = buffer.slice(0, boundary);
      buffer = buffer.slice(boundary + 2);
      boundary = buffer.indexOf("\n\n");
 
      let event = "message";
      let id: string | undefined;
      const data: string[] = [];
      for (const line of raw.split("\n")) {
        if (line.startsWith(":")) continue; // comment, often used as a keep-alive heartbeat
        if (line.startsWith("event:")) event = line.slice(6).trim();
        else if (line.startsWith("id:")) id = line.slice(3).trim();
        else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
      }
      if (data.length > 0) yield { event, data: data.join("\n"), id };
    }
  }
}
scripts/client.ts
import { readSSE } from "../lib/read-sse.ts";
 
const BASE = process.env.API_URL ?? "http://localhost:3000";
const json = { "Content-Type": "application/json" };
 
// 1. Sign in. A browser stores the cookie automatically; a script has to keep it itself.
const login = await fetch(`${BASE}/api/login`, {
  method: "POST",
  headers: json,
  body: JSON.stringify({ email: "ada@example.com", password: "correct-horse-battery-staple" }),
});
if (!login.ok) throw new Error(`login failed: ${login.status} ${await login.text()}`);
const cookie = login.headers.getSetCookie()[0]!.split(";")[0]!;
console.log("signed in, session cookie received");
 
// 2. Create an API key with the session, as a user would in a settings page.
const created = (await (await fetch(`${BASE}/api/keys`, { method: "POST", headers: { cookie } })).json()) as { id: string; key: string };
console.log(`created API key ${created.id}`);
 
// 3. From now on, act like another program: authenticate with the key only.
const response = await fetch(`${BASE}/api/generate`, {
  method: "POST",
  headers: { ...json, Authorization: `Bearer ${created.key}` },
  body: JSON.stringify({ prompt: "Explain Server-Sent Events in one sentence." }),
});
 
let lastId: string | undefined;
for await (const message of readSSE(response)) {
  lastId = message.id;
  const data = JSON.parse(message.data) as { text?: string; requestId?: string; tokens?: number; message?: string };
  if (message.event === "start") console.log(`stream ${data.requestId} started`);
  else if (message.event === "token") process.stdout.write(data.text ?? "");
  else if (message.event === "done") console.log(`\ndone: ${data.tokens} tokens, last event id ${lastId}`);
  else if (message.event === "error") throw new Error(data.message);
}
node scripts/client.ts
signed in, session cookie received
created API key key_d571543bfafe
stream 27bae9a3-867e-4815-9f74-a69cb77e59d7 started
(Simulated reply.) You wrote 6 words. A real model would answer: "Explain Server-Sent Events in one sentence."
done: 17 tokens, last event id 18

Run npm run build too. Every API route is listed as ƒ (Dynamic), rendered on demand: they read cookies, headers, or a request body, so nothing about them can be prerendered.

Common mistakes#

  • Taking the user ID from the request body. Anyone can type any ID. Identity comes only from a verified session or key.
  • Different errors for "no such user" and "wrong password". That turns your login form into a tool for discovering which emails have accounts. Same message, same timing.
  • Storing API keys in plain text. A database leak becomes a working-credentials leak. Store a hash and show the key once.
  • Letting API keys create API keys. A leaked key then gives an attacker a way to keep access after you revoke it.
  • Expecting errors as status codes mid-stream. After the first byte the status is fixed. Send an error event and make clients handle it.
  • An in-memory rate limiter behind a load balancer. Each instance counts separately, so the real limit is multiplied by the instance count. Use Redis once you run more than one.

Try it

Harden the API

  1. Add DELETE /api/keys/[id] that revokes a key. It must only delete keys belonging to the caller, and return 404 (not 403) for keys that are not theirs.
  2. Add X-RateLimit-Remaining to successful generate responses, so clients can slow down before they hit 429.
  3. Replace the in-memory limiter with the Redis rateLimit from Tutorial 4. Start two dev servers on different ports and confirm the limit is now shared.
  4. Make the client resume: if the stream drops, reconnect with a Last-Event-ID header and have the server skip events the client already has. (Hint: this needs the server to remember generated events per requestId.)

Check your understanding

Why does the login route verify the password against a dummy hash when the email does not exist?
Why can a fast SHA-256 hash be used for API keys but not for passwords?
The stream has started and the model call fails halfway. How should the server tell the client?

Key takeaways

  • Order the checks: authenticate, rate-limit, validate, and only then do expensive work.
  • Sessions use HMAC-signed, HttpOnly cookies; API keys are random, shown once, and stored as hashes.
  • Login reveals nothing: one error message, equal timing, and a per-account rate limit.
  • Use one error shape everywhere, 401 for "who are you?" and 403 for "no", and Retry-After on 429.
  • SSE streams carry ids and heartbeats, stop on disconnect, and report mid-stream failures as events.

Next steps#

Finished this article? Track it to see your progress on the roadmap.