Put the LLM call behind your own backend so the API key never ships in a browser, mobile app or desktop bundle; authenticate your user, then call the provider with a key read from a secrets manager. Redact PII before it reaches the prompt and before anything is logged, and choose the provider's data-retention setting deliberately (both OpenAI and Anthropic delete API data within about 30 days by default, with zero-data-retention available by agreement). Ask for structured output, validate it against a schema on your server, and still treat the values as untrusted. Bound every call with connect and read timeouts, bounded retries and a per-user cost cap, and log the request id, token usage and latency, never the key or the raw prompt.
- Anything shipped to a client is extractable, so an API key in a browser, mobile or desktop build is a leaked key; call the provider from your backend instead.
- Use a distinct, least-privilege project or workspace key per service, from a secrets manager, with rotation and a leaked-key runbook.
- Redact or pseudonymize PII before the prompt and before logs; data minimization is the control that survives a provider incident.
- By default OpenAI keeps abuse-monitoring logs for up to 30 days and Anthropic deletes API inputs and outputs within 30 days; zero data retention needs the provider’s approval and excludes some endpoints and models, and flagged content is kept longer.
- Structured outputs make parsing reliable but not trustworthy: re-validate on the server and treat the result as untrusted input.
- Set timeouts (the SDKs default to 10 minutes), bound retries, cap max output tokens, and reserve per-user budget before the call; log the request id and usage, never the key or the raw prompt.
| Setting | Value | Note | Source |
|---|---|---|---|
| API key location | Backend only, from a secrets manager or environment; never in a client | NEXT_PUBLIC_ and VITE_ prefixes expose a value to the browser bundle. A shipped key is extractable. | OpenAI production best practices; Anthropic API docs |
| Key scope | One least-privilege project (OpenAI) or workspace (Anthropic) key per service | Scope and rotate; revoke on leak (see leaked-secret-response). | OpenAI projects; Anthropic Console workspaces |
| PII in prompts and logs | Redacted or pseudonymized before the call and before logging | Reversible pseudonymization keeps a server-side map; irreversible redaction drops it. | OWASP LLM02:2026 Sensitive Information Disclosure |
| OpenAI data retention | Abuse-monitoring logs up to 30 days by default; ZDR available | ZDR treats store as false on /v1/responses and /v1/chat/completions and keeps content out of abuse logs; endpoints that keep application state, such as Files, Batches, Vector stores, Conversations and fine-tuning, are not ZDR-eligible. | OpenAI: how we use and retain your data |
| Anthropic data retention | Inputs and outputs deleted within 30 days by default; ZDR by agreement | Longer for features you control, such as the Files API. Flagged content is kept up to 2 years and classifier scores up to 7 years. Covered Models require 30-day retention. | Anthropic: how long do you store personal data |
| Structured output | Schema-constrained, then re-validated on the server and treated as untrusted | OpenAI responses.parse with a Pydantic model or zodTextFormat; Anthropic messages.parse with output_format. Validation proves shape, not truth. | OpenAI Structured Outputs; untrusted-model-output |
| Timeout and retries | An explicit per-call timeout and bounded retries | The OpenAI and Anthropic SDKs default to a 10-minute timeout and 2 retries; set a shorter timeout for interactive calls. | OpenAI and Anthropic SDK README (current) |
| Cost cap | A max output-token cap and a per-user budget reserved before the call | Reconcile from usage after the call; also set provider spend limits per project or workspace. | ai-cost-memory-operations |
| End-user identifier | A stable hash of the user id, sent to the provider for abuse monitoring | OpenAI safety_identifier; Anthropic metadata.user_id. Hash the id; never send an email. | OpenAI safety best practices |
- Grep the client bundle (web, mobile, desktop) for key prefixes such as
sk-andsk-ant-; any hit is a leaked key that must be rotated and moved server-side. - Call the backend LLM endpoint without a session and confirm it is rejected before any provider call is made.
- Send a record with a known email, card number and national id through the path and confirm the redacted form reaches the prompt and the logs, not the raw values.
- Record the provider's current data-retention and zero-data-retention settings for your account, with the date checked, and confirm your account's configuration matches what you documented.
- Feed the model-output parser a response with an extra field, a wrong type and an out-of-range value and confirm it is rejected before the value is used.
- Simulate a slow provider and confirm the call times out and the user sees a handled error; force a 429 and confirm bounded retry with backoff, not an unbounded loop.
- Grep logs for the API key, raw prompts and PII; confirm only the request id, token usage, model and latency are recorded.
- Applies to
- openai (Python / Node) 3.28.0 / 7.32.0
- anthropic (Python) / @anthropic-ai/sdk (Node) 1.13.0 / 0.133.0
- Pydantic / zod 2.14.0 / 4.6.5 tested
- Presidio presidio-analyzer / presidio-anonymizer 2.2.364
- FastAPI 0.143.0
Backend code that calls a hosted LLM API (OpenAI or Anthropic shown). Provider settings and SDK APIs below are current as of October 2026; pin your SDK and re-check the provider's data-control docs.
- Part of
- Workflow: Secure a feature
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Keep the API key server-side and out of every client, with rotation and revocation.
- Redact PII before it reaches the prompt and the logs, and set provider retention deliberately.
- Validate structured output against a schema and still treat it as untrusted.
- Bound each call with timeouts, retries and per-user cost caps, and log without leaking.
Before you start
Developers wiring an application to a hosted LLM API, and reviewers of that integration.
- Build a backend endpoint and read environment configuration.
- Know what PII is and why prompts and logs are sensitive.
Refresh first: Keep generated output out of executable contexts
Go to the guided practiceCall the provider from your backend, never from the client
- Authenticated requestThe browser or app calls your endpoint with the user’s session; it holds no provider key.
- Gate and reserveThe backend rejects anonymous calls, applies the per-user quota and reserves budget.
- Minimize the inputPII in the input is redacted or pseudonymized before it reaches the prompt or the logs.
- Bounded provider callThe backend calls the provider with a key from the secrets manager, a short timeout, bounded retries, an output cap and a hashed user id.
- Validate and recordThe backend re-validates the structured output against a strict schema, charges actual usage and logs only the request id and token counts.
- Return validated fieldsOnly validated fields go back, still treated as untrusted wherever they are rendered or stored.
The most common LLM-integration mistake is calling the provider straight from the client. Anything you ship to a browser, mobile app or desktop binary can be extracted, so an API key embedded there is a leaked key, and build-time environment prefixes such as NEXT_PUBLIC_ and VITE_ copy their values into the browser bundle. Put the LLM call behind your own backend endpoint: the client calls you, you authenticate the user and apply quotas, and only your server holds the provider key. The narrow exception is a provider’s own short-lived client token, such as an OpenAI Realtime client secret minted server-side with POST /v1/realtime/client_secrets, which is scoped and expires; use that mechanism rather than shipping the real key.
Read the key from a secrets manager or an injected environment variable, never from source control. Use a distinct, least-privilege key per service: OpenAI project API keys with restricted permissions, or an Anthropic Console workspace key. Rotate on a schedule, and keep a revoke-first runbook for a leak (see /domains/identity-crypto/secrets/leaked-secret-response/). The SDKs read the key from the environment by default, so you rarely pass it in code.
import os
from openai import OpenAI
from anthropic import Anthropic
# Keys come from the environment, injected from a secrets manager at deploy time.
# They are never hard-coded and never sent to a client.
openai = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
timeout=20.0, # seconds; SDK default is 10 minutes
max_retries=2, # bounded; SDK default is 2
)
anthropic = Anthropic(
api_key=os.environ["ANTHROPIC_API_KEY"],
timeout=20.0,
max_retries=2,
)
# Read the model from config, so it is not hard-coded and can be changed per environment.
MODEL = os.environ["LLM_MODEL"]Send the model the least it needs. Strip or pseudonymize personal data before it reaches the prompt, so a provider log, a future training-data question or an incident exposes as little as possible; this is the data-minimization half of OWASP LLM02:2026 Sensitive Information Disclosure. Choose reversible pseudonymization, keeping a server-side map so you can re-expand identifiers in the response, when the model needs a stable token, and irreversible redaction otherwise. In Python, Presidio, which Microsoft started and a community project now maintains, detects and anonymizes many entity types; for a few structured identifiers a pattern-based redactor is enough, with the honest caveat that pattern matching misses names, addresses and free-text PII.
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
analyzer = AnalyzerEngine() # needs a spaCy model, e.g. en_core_web_lg
anonymizer = AnonymizerEngine()
def redact(text: str) -> str:
results = analyzer.analyze(
text=text,
language="en",
entities=["EMAIL_ADDRESS", "PHONE_NUMBER", "CREDIT_CARD", "US_SSN", "PERSON"],
)
return anonymizer.anonymize(text=text, analyzer_results=results).text
# Redact before building the prompt, and redact (or omit) before logging.
safe_prompt = redact(user_record)On Node there is no Presidio; for structured identifiers a vetted set of patterns, applied before the prompt and before logging, covers the common cases. Tested in Node, this replaced an email, a US national id and a card number with labels. Treat it as a floor, not a complete PII engine: it will not catch names or addresses, so still minimize what you send.
const PATTERNS: Array<[string, RegExp]> = [
["EMAIL", /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g],
["CARD", /\b(?:\d[ -]?){13,16}\b/g],
["US_SSN", /\b\d{3}-\d{2}-\d{4}\b/g],
];
export function redact(text: string): string {
return PATTERNS.reduce((out, [label, re]) => out.replace(re, `[${label}]`), text);
}Know, and record, how long your provider keeps the data you send. As of October 2026, OpenAI keeps abuse-monitoring logs for up to 30 days by default and, for approved customers, offers Zero Data Retention, which treats store as false on /v1/responses and /v1/chat/completions and keeps customer content out of abuse-monitoring logs; endpoints that keep application state, such as Files, Batches, Vector stores, Conversations, Assistants and fine-tuning jobs, are not ZDR-eligible. Anthropic deletes API inputs and outputs within 30 days of receipt or generation by default, except for features whose retention you control (such as the Files API), a zero data retention agreement, Usage Policy enforcement and legal requirements; flagged content is kept up to 2 years and trust-and-safety classifier scores up to 7 years, and since 9 June 2026 the models Anthropic designates as Covered Models require 30-day retention, so they are generally unavailable under ZDR. These values change, so verify them against the provider’s data-control pages for your account and record the date you checked. OpenAI’s Responses API keeps application state for 30 days by default; set store: false when you don’t need it, understanding that this does not remove abuse-monitoring logs unless you are on ZDR.
Structured outputs make the response parse reliably into your schema, which removes a class of brittle string handling. They do not make the content true or safe: a schema-valid object can still carry a prompt-injection payload or a wrong answer, so re-validate on your server and treat the values as untrusted input (see /domains/ai-emerging/llm-security/untrusted-model-output/). With the OpenAI Python SDK, responses.parse takes a Pydantic model as text_format and returns output_parsed; in Node, pass zodTextFormat from openai/helpers/zod. Handle a refusal or a truncated response (hitting the output-token cap) as a failure, not as data.
from pydantic import BaseModel, Field
class Draft(BaseModel):
title: str = Field(min_length=1, max_length=200)
summary: str = Field(min_length=1, max_length=2000)
def summarize(text: str, user_id: str) -> Draft:
resp = openai.responses.parse(
model=MODEL,
input=redact(text), # PII removed before the prompt
text_format=Draft, # schema-constrained output
max_output_tokens=800, # cost and truncation cap
safety_identifier=hash_user(user_id), # a hash, never an email
)
draft = resp.output_parsed # already validated against Draft
log_call(resp) # request id and usage, not content
return draft # still treat fields as untrusted downstreamEven when the provider constrains output to a schema, re-validate with your own schema on the server so a provider or SDK change can't slip an unexpected shape through. With zod's z.strictObject, an extra field or a wrong type fails; the tested snippet rejected an object with an empty title and an extra field. Use the parsed, re-validated value, and still encode it for whatever sink it reaches.
import { z } from "zod";
const Draft = z.strictObject({
title: z.string().min(1).max(200),
summary: z.string().min(1).max(2000),
});
// Re-validate the model's output on the server, treating it as untrusted.
export function parseDraft(output: unknown) {
const result = Draft.safeParse(output);
if (!result.success) {
throw new Error(`model output failed schema: ${result.error.issues[0].message}`);
}
return result.data;
}An LLM call is a network call to a slow dependency, so bound it. The OpenAI and Anthropic SDKs default to a 10-minute timeout and 2 retries; set a shorter timeout for interactive requests and keep retries bounded with backoff (the SDKs retry connection errors, 408, 409, 429 and 5xx). The Anthropic SDKs also raise an error for a non-streaming request expected to take longer than about 10 minutes unless you stream or set a timeout. Cap the output with max_output_tokens (OpenAI) or max_tokens (Anthropic) so a runaway generation can’t run up the bill. For per-user cost control, reserve budget before the call and reconcile from the usage the response reports. Set provider spend limits as a backstop: an OpenAI organization or project hard limit returns 429 with project_spend_limit_exceeded or organization_spend_limit_exceeded, and Anthropic workspaces take a monthly spend limit. The cost and memory guide at /domains/ai-emerging/agent-security/cost-memory-operations/ covers budget reservation in depth.
# Reserve budget before the call, then reconcile from reported usage.
def call_with_budget(user_id: str, text: str) -> Draft:
reservation = budget.reserve(user_id, estimated_tokens=1200) # raises if over cap
try:
resp = openai.with_options(timeout=15.0).responses.parse(
model=MODEL,
input=redact(text),
text_format=Draft,
max_output_tokens=800,
safety_identifier=hash_user(user_id),
)
finally:
reservation.release()
budget.charge(user_id, tokens=resp.usage.total_tokens) # actual spend
log_call(resp)
return resp.output_parsedLog what you need to diagnose a problem and nothing that is sensitive. Record the provider request id (both SDKs expose it as response._request_id, read from the x-request-id header for OpenAI and request-id for Anthropic), the token usage, the model and the latency; a hash of the prompt is fine for correlating. Never log the API key, the raw prompt or a response that may contain PII. Turn off the SDKs’ debug logging in production (OPENAI_LOG and ANTHROPIC_LOG): at debug level the Anthropic TypeScript SDK logs request and response bodies. Make sure your HTTP client isn’t dumping request bodies either. Keep your own logs on a short retention and access them under the same controls as other sensitive data (see /domains/appsec/secure-coding/secure-error-handling/ and /domains/runtime/detection/security-event-logging/).
import logging
log = logging.getLogger("llm")
def log_call(resp) -> None:
# Request id and usage only: no key, no prompt, no response content.
log.info(
"llm_call",
extra={
"request_id": getattr(resp, "_request_id", None),
"model": MODEL,
"input_tokens": resp.usage.input_tokens,
"output_tokens": resp.usage.output_tokens,
},
)A minimal FastAPI endpoint shows the whole shape: authenticate the user, enforce a quota, redact the input, call the provider with a bounded timeout and a schema, and return only the validated result. The provider key never leaves the server, and the client never talks to the provider directly.
from fastapi import Depends, FastAPI, HTTPException
app = FastAPI()
@app.post("/summarize")
def summarize_endpoint(body: SummarizeIn, user=Depends(current_user)):
if user is None:
raise HTTPException(status_code=401) # no anonymous provider calls
if not quota.allow(user.id):
raise HTTPException(status_code=429) # per-user cost cap
try:
draft = call_with_budget(user.id, body.text) # redact + timeout + schema
except TimeoutError:
raise HTTPException(status_code=504, detail="model timed out")
return {"title": draft.title, "summary": draft.summary}The controls are provider-independent. With the Anthropic SDK, the client reads ANTHROPIC_API_KEY from the environment and takes the same timeout and max_retries options; set max_tokens to cap output, pass a hashed end-user id as metadata.user_id for abuse monitoring, and read token counts from response.usage (input_tokens and output_tokens) to reconcile budget. For schema-constrained output, call client.messages.parse() with output_format set to a Pydantic model (the SDK sends it as output_config.format) and read parsed_output, or use a strict tool definition, then re-validate the result on your server exactly as above. Read the model id from configuration rather than hard-coding it.
resp = anthropic.messages.create(
model=MODEL, # from config, e.g. a current Claude model id
max_tokens=800, # output cap
metadata={"user_id": hash_user(user_id)}, # hashed id, not an email
messages=[{"role": "user", "content": redact(text)}],
)
# resp.usage.input_tokens / resp.usage.output_tokens for budget reconciliation;
# resp._request_id for logging. Validate and treat resp.content as untrusted.Guided practice
A team shipped an AI 'reply drafting' feature quickly and asked for a security pass before launch. The findings below come from the review. For each, say what is wrong and what to change.
Supplied scenario material
F1 The React app calls the provider directly: fetch("https://api.openai.com/v1/responses",
{ headers: { authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_KEY}` } }).
F2 The whole customer record, including email and card number, is put in the prompt.
F3 No one can say how long the provider keeps the data; ZDR has never been discussed.
F4 The response text is parsed with JSON.parse and the fields are used directly in an email and a DB write.
F5 There is no timeout; one hung request pinned a worker for ten minutes.
F6 Each request logs the full prompt and the response "for debugging."
F7 There is no per-user limit; one account ran up a large bill overnight.1. Move the key server-side
Explain why F1 is a leaked key and the fix.
Expected result and reasoning
NEXT_PUBLIC_copies the key into the browser bundle, where anyone can read it; it is already compromised. Rotate it immediately, remove it from the client, and call the provider from a backend endpoint that authenticates the user. If the client needs direct streaming, mint a short-lived provider client token server-side instead of shipping the real key.2. Minimize and redact
Describe what to do about F2.
Expected result and reasoning
Send only the fields the model needs, and redact or pseudonymize PII (email, card number, national id) before the prompt and before any log. This limits exposure through provider retention and any future incident (OWASP LLM02:2026).
3. Decide retention
Explain what F3 should become.
Expected result and reasoning
Record the provider's current default retention (about 30 days for both OpenAI and Anthropic) and whether flagged content is kept longer, with the date checked; decide whether the data classification requires Zero Data Retention and, if so, enable it and confirm the endpoints you use are ZDR-eligible.
4. Validate and distrust the output
Explain the risk in F4 and the fix.
Expected result and reasoning
Raw
JSON.parseaccepts any shape, and using the fields directly trusts model output in an email and a database write. Use schema-constrained output, re-validate against a strict schema on the server, and encode or parameterize the values for each sink; treat them as untrusted.5. Bound timeout, retries and cost
Give the fixes for F5 and F7.
Expected result and reasoning
Set a short per-call timeout (the SDK default is 10 minutes) with bounded retries and backoff; cap max output tokens; reserve per-user budget before the call and reconcile from usage; and set a provider project or workspace spend limit as a backstop.
6. Fix the logging
Explain what F6 should log instead.
Expected result and reasoning
Log the request id, token usage, model and latency, and at most a hash of the prompt; never the key, the raw prompt or a response that may contain PII. Turn off SDK debug logging in production and ensure the HTTP client is not dumping bodies.
The exercise reviews a design on paper. It does not run the integration, confirm your account’s retention settings or prove the redactor catches every form of PII; a launch also needs the verification steps above.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OpenAI: how we use and retain your data (data controls, Zero Data Retention)
- OpenAI: safety best practices (safety identifiers)
- OpenAI: structured outputs (responses.parse, zodTextFormat)
- OpenAI: spend limits
- OpenAI Python SDK README (timeout, max_retries, request id)
- OpenAI Node SDK README
- Anthropic: how long do you store my organization’s data? (API retention)
- Anthropic: data retention practices for Covered Models
- Anthropic: API and data retention (zero data retention scope)
- Anthropic Python SDK (timeouts, retries, request ids, structured outputs)
- OWASP Top 10 for LLM Applications 2026: LLM02 Sensitive Information Disclosure. 2026 edition (3 August 2026).
- Presidio: data protection and de-identification SDK
- OWASP ASVS 5.0.0, V13 Configuration. Version 5.0.0.
- CWE-798: Use of Hard-coded Credentials. CWE 4.20.
- CWE-532: Insertion of Sensitive Information into Log File. CWE 4.20.
Reference information
- Tags
- Denial of service and resource limitsSecrets managementPrivacy engineeringLLM application security
- SDLC phase
- DesignDevelopment
- ASVS 5.0.0 requirements
- v5.0.0-13.1.1 · v5.0.0-1.3.6