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).

bash
# 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.

User-facing

Verified session/JWT: identity from the token, never the body.

Service-to-service

Shared secret, constant-time compare, fail closed if unset.

Admin-only

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.

typescript
// 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.

typescript
// 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.

typescript
// 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.

typescript
// 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.

text
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?