How Unauthenticated API Access Happens in Node.js Express Endpoints and How to Fix It
The Problem in Plain Sight
The api/translate.js file is responsible for one job: accept a block of text, call either Anthropic Claude or Google Translate, and return a translation. Every call to those backends costs real money. Yet until this fix landed, any person on the internet — no account, no token, no relationship with the application — could POST to /api/translate and run up the bill.
This is not a subtle logic flaw. It is a missing front door lock on a room that contains a cash register.
The Vulnerability Explained
What the Code Did (and Didn't Do)
Before the fix, the exported handler function in api/translate.js looked roughly like this around line 95:
// api/translate.js (before fix) — line ~106 onward
export default async function handler(req, res) {
// ... method check (OPTIONS/POST) ...
// ❌ No authentication check here — jumped straight to input parsing
const { text, targetLang, sourceLang = 'en', context } = req.body || {};
if (!text || typeof text !== 'string' || !targetLang) {
res.status(400).json({ error: 'Missing required fields.' });
return;
}
// ... cache lookup, then API call to Anthropic or Google Translate ...
}
The handler validated what was being asked (is text a string? is targetLang present?) but never validated who was asking. The only friction between the open internet and a paid API call was:
- A basic
POSTmethod check. - Input validation on
textandtargetLang. - An in-memory cache — which resets on every cold start and provides zero protection against distributed abuse.
How an Attacker Would Exploit This
The attack requires zero sophistication:
# Anyone on the internet can do this — no credentials required
curl -X POST https://your-app.com/api/translate \
-H "Content-Type: application/json" \
-d '{"text": "Hello world", "targetLang": "es"}'
A motivated attacker could:
- Run a cost-exhaustion campaign — script thousands of unique translation requests per minute (bypassing the in-memory cache with varied inputs), draining the application's Anthropic or Google Translate API budget.
- Exfiltrate translated content — if the application's own users submit sensitive text for translation, an attacker who knows the endpoint can replay or mirror those requests.
- Probe the AI backend — use the unauthenticated endpoint to probe Anthropic Claude's behavior with adversarial prompts, potentially revealing system prompt structure or triggering unintended model outputs at the app owner's expense.
The PR description characterizes this as a 2-step chain: the attacker (1) discovers the public endpoint and (2) sends crafted POST requests. There is no step 3 — exploitation is immediate.
Why the In-Memory Cache Doesn't Save You
The handler includes a cache to avoid redundant API calls for identical inputs. This sounds like a partial mitigation, but it is not:
- The cache lives in process memory and resets on every cold start (relevant for serverless/Vercel deployments where
api/translate.jsis likely deployed). - An attacker generating unique
textvalues (even trivially, by appending a counter) will always miss the cache. - The cache is a performance optimization, not a security control.
The Fix
What Changed
The fix inserts a shared-secret authentication check immediately after the method/OPTIONS handling and before any body parsing or API calls:
// api/translate.js (after fix) — inserted at line ~109
const apiSecret = process.env.TRANSLATE_API_SECRET;
if (apiSecret) {
const provided = req.headers['x-translate-secret'];
if (!provided || provided !== apiSecret) {
res.status(401).json({ error: 'Unauthorized.' });
return;
}
}
Before vs. After
Before — handler flow:
POST /api/translate
→ method check
→ [NO AUTH CHECK]
→ parse body
→ validate text/targetLang
→ check cache
→ call Anthropic/Google Translate ← anyone reaches here
After — handler flow:
POST /api/translate
→ method check
→ check x-translate-secret header vs TRANSLATE_API_SECRET env var
→ mismatch? → 401 Unauthorized, stop
→ parse body
→ validate text/targetLang
→ check cache
→ call Anthropic/Google Translate ← only authorized callers reach here
Why This Specific Fix Works
- Fail-closed when configured: If
TRANSLATE_API_SECRETis set in the environment, every request without the correctx-translate-secretheader is rejected with a 401 before any downstream logic runs. - Opt-in for development: If
TRANSLATE_API_SECRETis not set (e.g., in a local dev environment where the variable isn't configured), the check is skipped. This prevents the fix from breaking local development workflows while ensuring production deployments — where the env var should always be set — are protected. - No business logic changes: The fix is scoped entirely to the authentication gate. Translation behavior, caching, and error handling for valid authenticated requests are completely unchanged.
- Cheap to evaluate: A string comparison on a request header is essentially free. There is no performance cost to this check.
Deploying the Fix
To activate the protection, set the environment variable in your deployment:
# Generate a strong random secret
openssl rand -hex 32
# → e.g., 4a7f2c9b1d3e5f8a0b2c4d6e8f1a3b5c...
# Set it in your environment (Vercel, .env, etc.)
TRANSLATE_API_SECRET=4a7f2c9b1d3e5f8a0b2c4d6e8f1a3b5c...
Callers (your own frontend, internal services) must then include the header:
// Authorized client call
fetch('/api/translate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-translate-secret': process.env.TRANSLATE_API_SECRET
},
body: JSON.stringify({ text: 'Hello', targetLang: 'es' })
});
Prevention & Best Practices
1. Apply Authentication at the Earliest Possible Point
The fix places the auth check before body parsing. This is the right pattern — don't do expensive or sensitive work before verifying the caller. In Express-style middleware:
// Middleware pattern — even better for multi-endpoint apps
function requireTranslateSecret(req, res, next) {
const secret = process.env.TRANSLATE_API_SECRET;
if (secret && req.headers['x-translate-secret'] !== secret) {
return res.status(401).json({ error: 'Unauthorized.' });
}
next();
}
app.post('/api/translate', requireTranslateSecret, handler);
2. Never Expose Paid API Proxies Without Authentication
Any endpoint that proxies a paid third-party service (OpenAI, Anthropic, Google APIs, Twilio, SendGrid, etc.) is a direct financial liability if left unauthenticated. Treat these endpoints with the same care as payment endpoints.
3. Use Secrets Management, Not Hardcoded Values
The fix correctly reads the secret from process.env.TRANSLATE_API_SECRET. Never hardcode secrets in source. Use:
- Vercel: Project environment variables
- AWS: Secrets Manager or Parameter Store
- Docker: --env-file or mounted secrets
4. Supplement with Rate Limiting (Defense in Depth)
Authentication is the primary control. Rate limiting is a secondary layer. Even authenticated users could abuse the endpoint if no per-user limits exist:
import rateLimit from 'express-rate-limit';
const translateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 20, // 20 requests per minute per IP
standardHeaders: true,
legacyHeaders: false,
});
5. Monitor API Costs as a Security Signal
Set up billing alerts on Anthropic and Google Cloud. A sudden spike in API spend is often the first observable indicator of endpoint abuse. Treat cost anomalies as security incidents.
6. Relevant Standards
- OWASP API Security Top 10 — API2:2023: Broken Authentication
- CWE-306: Missing Authentication for Critical Function
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
Key Takeaways
- The
/api/translatehandler inapi/translate.jshad zero authentication before line 109 — any anonymous HTTP client could invoke Anthropic Claude or Google Translate at the application owner's expense. - In-memory caches are not security controls — they reset on cold starts and are trivially bypassed with unique inputs, providing no meaningful protection against cost-abuse attacks.
- The fix is a single, targeted block — a
TRANSLATE_API_SECRETenvironment variable check against thex-translate-secretrequest header, inserted before any body parsing or API calls. - Paid API proxy endpoints deserve authentication by default — if your Node.js handler calls a metered third-party service, assume it will be found and abused if left unauthenticated.
- The opt-in design (
if (apiSecret)) preserves local dev ergonomics while ensuring production deployments with the env var set are fully protected.
How Orbis AppSec Detected This
- Source: Unauthenticated HTTP POST request to
/api/translate— specifically, thereq.bodyobject consumed at line ~112 ofapi/translate.jswith no prior identity check. - Sink: The downstream call to the Anthropic Claude or Google Translate API client within the same handler — a paid, rate-limited external service triggered on every cache miss.
- Missing control: No authentication middleware, no API key validation, no JWT check, and no session verification existed anywhere in the handler's execution path before the API client was invoked.
- CWE: CWE-306 — Missing Authentication for Critical Function.
- Fix: Inserted a shared-secret header check (
x-translate-secretvs.TRANSLATE_API_SECRET) at line 109 ofapi/translate.js, returning HTTP 401 before any business logic executes for unauthenticated requests.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
Missing authentication on a paid API proxy is one of those vulnerabilities that feels obvious in retrospect but is easy to miss during rapid development — especially when you're focused on getting the translation feature working and the input validation looks solid. The /api/translate endpoint had reasonable validation of what it received, but no validation of who was sending it.
The fix is small — nine lines — but the security improvement is significant. By checking the x-translate-secret header against TRANSLATE_API_SECRET before any other logic runs, the handler now enforces a clear boundary: only callers who know the secret can trigger a translation. Everyone else gets a 401.
If your Node.js application proxies any paid or sensitive backend service, audit your handlers today. Ask one question for each endpoint: "What stops an anonymous HTTP client from reaching the expensive part?" If the answer is "nothing," you have the same vulnerability that was just fixed here.
References
- CWE-306: Missing Authentication for Critical Function
- OWASP API Security Top 10 — API2:2023 Broken Authentication
- OWASP Authentication Cheat Sheet
- Node.js Environment Variables Best Practices
- Semgrep rules for missing authentication
- fix: the /api/translate endpoint is publicly accessi... in translate.js