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.
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#
| Endpoint | Auth | What it does |
|---|---|---|
POST /api/login | none | Checks email and password, sets a signed session cookie |
POST /api/logout | none | Clears the cookie |
GET /api/me | cookie or key | Says who is calling, and how |
POST /api/keys | cookie only | Creates an API key, shown once |
POST /api/generate | cookie or key | Validates, rate-limits, and streams a reply as SSE |
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" }Show text descriptionHide 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.
- Authenticate. Read the API key from the Authorization header, or verify the signed session cookie. No valid identity means 401.
- Rate limit. Count this caller's requests in the current window. Over the limit means 429, with Retry-After saying when to come back.
- Validate. Parse the body with Zod. Invalid input means 400 with every issue listed.
- 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#
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]"--apicreates 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 useimportwithout a warning.Set the session secret
node -e "console.log('SESSION_SECRET=' + require('crypto').randomBytes(32).toString('base64url'))" > .env.localThe 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).Allow .ts imports in scripts
Add
"allowImportingTsExtensions": truetocompilerOptionsintsconfig.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.
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:
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.
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:
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:
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:
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:
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#
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.
import { clearSession } from "@/lib/session";
export async function POST() {
await clearSession();
return new Response(null, { status: 204 });
}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);
}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:
/** 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:
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:
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/generateid: 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 429The 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:
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 };
}
}
}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.tssigned 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 18Run 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
errorevent 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
- 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. - Add
X-RateLimit-Remainingto successful generate responses, so clients can slow down before they hit 429. - Replace the in-memory limiter with the Redis
rateLimitfrom Tutorial 4. Start two dev servers on different ports and confirm the limit is now shared. - Make the client resume: if the stream drops, reconnect with a
Last-Event-IDheader and have the server skip events the client already has. (Hint: this needs the server to remember generated events perrequestId.)
Check your understanding
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-Afteron 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.
Related articles
- APIs for AI Apps: REST, Server-Sent Events, WebSockets, and AuthenticationChoose between REST, Server-Sent Events, and WebSockets for AI features, stream events from a Next.js route handler, and secure every endpoint with sessions.
- LLM API Keys, Billing, and Security for Web DevelopersKeep LLM API keys server-side, understand token-based billing, compute the cost of every call, and enforce per-user budgets, timeouts, and retries in Node.js.