Securing Endpoints
Stop trusting whoever can reach the URL. Every route that writes data, sends email, or triggers a side effect needs to answer one question: who is allowed to call this?, even the ones you think are internal-only.
The mistake in one sentence: AI coding tools happily generate an endpoint that does something powerful (sends an email, triggers a webhook side-effect, writes to a database) without asking “who is allowed to call this?”, because nobody asked it to.
Paste into your AI chat
Copy the entire guide as markdown to use as context in Cursor, Claude, ChatGPT, or any AI tool.
Why AI tools generate this
Functionally complete and security-incomplete look identical in a quick test.
AI coding assistants are optimised to produce code that satisfies the request and passes the obvious test: “does the email send when the webhook fires?” Authentication is a cross-cutting concern: it’s not implied by “build an endpoint that sends a welcome email,” so a model has to proactively add it, and proactively adding unrequested code is exactly what generation stops short of unless you ask.
The result is an endpoint that is functionally complete and security-incomplete at the same time, and both look identical in a quick manual test. The same blind spot produces the sneakier variants below: trusting identity fields from the request body, and interpolating user input straight into email headers.
The three ways this goes wrong
1. ‘Internal’ endpoints with no auth
A webhook or admin action calls a second internal endpoint over plain HTTP, left open because “only we call it.”
2. Trusting identity from the request body
An endpoint reads userId/email/role from the POST body and uses it directly, so anyone can impersonate anyone.
3. Email header injection
Raw string-interpolation into email headers with no CRLF stripping turns your contact form into a spam relay.
Why it’s dangerous
A single open email endpoint is enough to burn your sending domain.
Open relay / phishing-as-a-service
Someone scripts requests against your endpoint and sends convincing, branded phishing emails that appear to come from your real domain.
Reputation and deliverability damage
Your sending domain gets flagged as a spam source; legitimate transactional email (password resets, receipts) starts landing in spam for everyone.
Quota exhaustion and cost
Providers bill per send or cap volume; an attacker can drain your quota or trigger unexpected charges.
Indirect PII disclosure
An endpoint that pulls a user’s profile by a body-supplied userId gives an attacker an oracle for enumerating account data tied to arbitrary IDs.
Header injection escalates the blast radius
Full control of Bcc/Cc, and in some mail libraries the message body itself, turns “send an email” into “send arbitrary emails to arbitrary people using your infrastructure.”
How to check your own app
Run these against your codebase (adjust paths for your stack).
# 1. Find all API route handlers
grep -rn "export default async function handler\|export async function POST\|app.post\|router.post" --include="*.ts" --include="*.tsx" .
# 2. For each one, check whether it verifies a session/token before doing anything
# Look specifically for routes with NO match on these auth patterns:
grep -L "requireAuth\|verifyToken\|getUser(\|auth.uid()\|Authorization" $(grep -rl "export default async function handler\|app.post" --include="*.ts" .)
# 3. Find places that trust body-supplied identity instead of a verified session
grep -rn "req.body.userId\|req.body.email\|body\.userId\|body\.email" --include="*.ts" .
# For each hit: is userId/email later used to authorize an action, or just derived
# from req.user / a verified JWT? If it comes from the body, that's the bug.
# 4. Find raw email/header construction that could be CRLF-injected
grep -rn "From:.*\${" --include="*.ts" .
grep -rn "\\\\r\\\\n" --include="*.ts" .Then manually list every endpoint called by your own backend (webhook → internal endpoint, cron → internal endpoint) that assumes it doesn’t need its own auth. That assumption is almost always wrong the moment the URL is guessable or shows up in a network tab.
The fix
Every endpoint needs to answer “who’s allowed to call this?” There are three correct patterns: a user-facing action requires a verified session (derive identity from it, never the body); an internal service-to-service call requires a shared secret checked with a constant-time comparison that fails closed; an admin action requires a verified admin session checked server-side against the database.
Verified session/JWT: identity from the token, never the body.
Shared secret, constant-time compare, fail closed if unset.
Verified admin session checked against the DB, not a UI guard.
Shared auth helpers
One verified-session helper and one internal-secret helper. The secret check uses a constant-time comparison and fails CLOSED if the env var is unset.
// api/lib/auth.ts — shared helpers
import { timingSafeEqual } from 'crypto';
export function verifyInternalSecret(req: Request): boolean {
const expected = process.env.INTERNAL_API_SECRET;
const provided = req.headers['x-internal-secret'];
if (!expected) return false; // fail CLOSED if unset, never fail open
if (typeof provided !== 'string' || provided.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
}
export async function verifyUser(req: Request): Promise<{ id: string; email: string } | null> {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return null;
const { data, error } = await supabaseAdmin.auth.getUser(token);
if (error || !data.user) return null;
return { id: data.user.id, email: data.user.email! };
}The fixed endpoint
Verify the internal secret before anything, then Zod-validate the payload, then send with an escaping template. Functionally identical happy path, now closed to the public.
// api/send-welcome-email.ts — fixed
export default async function handler(req: Request, res: Response) {
if (!verifyInternalSecret(req)) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Even internal calls should pass a structured, validated payload —
// Zod-validate it, don't just trust the shape.
const parsed = welcomeEmailSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid payload' });
await sendEmail({
to: parsed.data.customerEmail,
from: 'noreply@example.com',
subject: `Welcome, ${escapeHtml(parsed.data.customerName)}!`,
html: renderWelcomeTemplate(parsed.data), // template escapes all interpolated values
});
return res.status(200).json({ sent: true });
}The caller sends the secret
Your webhook handler attaches the shared secret header on every internal call, so the receiver can require it.
// The caller (your webhook handler) sends the secret on every internal call
await fetch(`${INTERNAL_BASE_URL}/api/send-welcome-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-internal-secret': process.env.INTERNAL_API_SECRET!,
},
body: JSON.stringify({ customerEmail, customerName, courseLink }),
});Stop header injection at the boundary
Reject line breaks at the schema level AND strip \r\n at the point of use. Don’t rely on validation alone: the next caller may skip it.
// Reject newlines at the schema level
const emailFieldSchema = z.string().max(200).regex(/^[^\r\n]*$/, 'No line breaks allowed');
// AND defensively strip at the email-sending boundary, so a bypass anywhere
// upstream still can't inject headers
function sanitizeHeaderValue(value: string): string {
return value.replace(/[\r\n]/g, '');
}Checklist
Verify each item before you ship an endpoint that has side effects.
- Every endpoint that writes data, sends email, or triggers a side effect has an explicit auth check, no exceptions for “internal” endpoints.
- Internal service-to-service calls use a shared secret, verified with a constant-time comparison, and fail closed (reject) if the secret env var is missing.
- User identity (userId, email, role) is always derived from a verified session/JWT, never read from the request body.
- Admin actions are checked against the database (a real is_admin/role lookup) server-side, not just gated by a client-side route guard.
- Any code that builds raw email headers strips \r\n from every interpolated value, in addition to schema-level validation.
- You’ve grepped for every app.post / route handler and can account for the auth story of each one.
- Error responses from these endpoints don’t leak internal error details.
Prompt your AI assistant
Paste this to have your AI tool audit, not fix, your endpoints.
Audit this repository for unauthenticated or under-authenticated API endpoints.
For every API route handler (Express routes, Next.js API routes/route handlers,
Vercel serverless functions, or equivalent in this stack), check:
1. Does it require a verified session, JWT, or API token before doing anything
state-changing (write, email send, external API call)? Flag any handler that
doesn't check auth before its first side effect.
2. Does it derive user identity (userId, email, role) from a verified
session/token, or does it trust `userId`/`email`/`role` fields from the
request body/query string? Flag the latter — that's an impersonation bug.
3. For any endpoint that is only ever supposed to be called by our own backend
(e.g. a webhook handler calling an internal email/notification endpoint),
confirm there's a shared-secret check using a constant-time comparison that
FAILS CLOSED (rejects the request) if the secret env var is missing or
unset. Flag any that fail open.
4. For any code that constructs raw email headers (From/To/Subject/Reply-To)
by string interpolation, confirm every interpolated value is stripped of
\r and \n and that the input schema also rejects line breaks. Flag any
missing this.
For each finding, give me: the file and line, a one-line description of the
exploit, and a concrete fix using patterns already present elsewhere in this
codebase where possible. Do not fix anything yet — just report.Want the full guide as context for your AI coding assistant?