TypeScript secure coding for AI applications starts from an uncomfortable fact: the compiler protects you from your own types, not from the model. Every value that comes back from an LLM — a tool name, a JSON argument, a URL, an HTML fragment — is attacker-influenced data. The type system will happily call it string and let you pass it to fetch.
This guide covers the TypeScript-specific failure modes we see most in LLM and agent codebases, with patterns you can apply today. It complements the product-level guidance in TypeScript AI Security and the SDK at resk-llm-ts.
1. Secrets that end up in the bundle
The classic mistake is a key prefixed for the client: NEXT_PUBLIC_OPENAI_KEY, VITE_ANTHROPIC_KEY, or an import of a config.ts from a component. Anything reachable from client code is public, even if it is minified.
- Keep provider keys server-side only. The browser talks to your endpoint, never directly to the model provider.
- Never
importa config module that reads secrets into a file bundled for the client; split server and shared modules explicitly. - Use short-lived tokens for browser sessions and scope them to your API, not to the provider.
- Add a build-time check that fails if a known key pattern appears in the output directory.
// config.server.ts — only imported by server code
import { z } from "zod";
const Env = z.object({
OPENAI_API_KEY: z.string().min(20),
MODEL_GATEWAY_URL: z.string().url(),
POLICY_VERSION: z.string(),
});
export const env = Env.parse(process.env);Validate environment variables at boot. A missing key should crash the process, not silently disable a filter.
2. Treat model output as untrusted input
Model output reaches your application as text. Before it becomes a tool call, a database query or HTML, it must pass a schema — and the schema must reject unknown fields.
import { z } from "zod";
const SendEmail = z.object({
to: z.string().email(),
subject: z.string().max(200),
body: z.string().max(10_000),
}).strict(); // unknown keys are an error, not a feature
const ToolCall = z.discriminatedUnion("name", [
z.object({ name: z.literal("send_email"), args: SendEmail }),
z.object({ name: z.literal("read_ticket"), args: z.object({ id: z.string().uuid() }).strict() }),
]);.strict() matters: a permissive parser lets an attacker smuggle extra properties into an object that later gets spread into a request. This is the same class of bug as mass assignment, with the model as the untrusted client.
3. Prototype pollution through JSON arguments
Tool arguments frequently arrive as JSON and are merged into defaults. A payload containing __proto__ or constructor.prototype can change object behaviour process-wide. Defences:
- Parse with a schema instead of spreading raw JSON into objects.
- Reject keys named
__proto__,prototypeandconstructorbefore merging. - Use
Object.create(null)for lookup maps built from user data, or aMap. - Keep dependencies patched; several popular deep-merge libraries have had this bug.
4. Authorize the tool call, not the prompt
A system prompt that says "only use read-only tools" is documentation. Authorization belongs in code that the model cannot influence:
type Capability = "read_ticket" | "write_ticket" | "send_email";
const TOOL_CAPABILITY = {
read_ticket: "read_ticket",
update_ticket: "write_ticket",
send_email: "send_email",
} as const;
export async function callTool(ctx: RequestContext, call: ToolCall) {
const required = TOOL_CAPABILITY[call.name];
if ((ctx.mask & CAP[required]) !== CAP[required]) {
audit.deny(ctx, call, "missing_capability");
throw new ForbiddenError("Action not permitted for this role");
}
if (!inScope(ctx, call)) {
audit.deny(ctx, call, "out_of_scope");
throw new ForbiddenError("Resource outside task scope");
}
if (IRREVERSIBLE.has(call.name) && !ctx.humanApproval) {
throw new ApprovalRequiredError(call.name);
}
return dispatch(call);
}Two details make this robust. First, the capability is resolved from a fixed map, not from a model-supplied string. Second, scope is checked against the actual arguments — the ticket ID, the recipient, the tenant — not just the tool name. The full model is described in Least Privilege for AI Agents.
5. SSRF in retrieval and tool URLs
Any feature where the model supplies a URL — a fetch tool, a web-retrieval step, an image loader — is an SSRF candidate. The model can be induced to request http://169.254.169.254/ or an internal admin host. If you must fetch model-supplied URLs:
- Allowlist schemes and domains; deny private, link-local and loopback ranges after DNS resolution.
- Re-resolve and re-check on redirects, or disable redirects.
- Run the fetch from an isolated egress proxy with no access to the internal network.
- Cap response size and time.
The same logic applies to tool names: a model-supplied string should never be used to import() a module or build a dynamic path.
6. Do not render model output as HTML
Markdown rendering is the usual culprit: a completion containing <img onerror=...> becomes XSS if passed to innerHTML. Sanitize with a maintained library, disable raw HTML, and prefer a renderer that produces a restricted AST. Never bind model output to dangerouslySetInnerHTML or eval-like sinks.
7. Log decisions, not secrets
TypeScript logging is often a thin wrapper around console.log(req), which captures the whole object — including the authorization header. Build a small logger that accepts an explicit allowlist of fields, redacts known patterns, and never serialises Request or provider clients. Correlate with a request ID so a tool call can be traced back to the prompt that caused it. Monitoring practice is covered in LLM Security Monitoring & Observability.
8. Supply chain and runtime
- Lock dependencies and review new AI SDKs before adding them; they often request broad network access.
- Run the model gateway in a separate process or container from the web tier, with its own egress policy.
- Set timeouts and abort signals on every provider call; an unbounded call is a denial-of-service primitive.
- Pin model versions where the provider allows it, and treat a silent upgrade as a change to retest.
- Disable prototype-modifying packages and run with
--frozen-intrinsicswhere practical.
A review checklist
- No provider key is reachable from client code; build output is scanned for key patterns.
- Every model output is parsed by a strict schema before use.
- Tool calls resolve capabilities from a fixed map and check argument scope.
- Irreversible actions require an approval outside the model's control.
- Model-supplied URLs are allowlisted and egress-proxied.
- Model output is sanitized before rendering; no raw HTML sinks.
- Logs are allowlisted and redacted; request IDs correlate prompts and tool calls.
- Provider calls have timeouts, retries with caps, and version pinning.
FAQ
What are the main TypeScript security issues in AI applications? Secrets bundled into client code, unvalidated model output used as tool arguments, prototype pollution through JSON merging, SSRF from model-supplied URLs, XSS from rendered completions, and over-broad SDK dependencies.
How do you keep API keys safe in a TypeScript LLM app? Keep them server-side only, never in NEXT_PUBLIC_/VITE_ variables or imported config modules, validate them at boot, and have the browser call your own gateway with a scoped session token.
How do you validate LLM tool calls in TypeScript? Parse the call with a discriminated union of strict schemas, resolve the capability from a fixed map, check the arguments against the caller's scope, and require human approval for irreversible actions.
Is TypeScript type safety enough to secure an LLM app? No. Types are erased at runtime and describe shapes, not trust. Validation, authorization and output handling must happen at runtime, on data you did not produce.
What TypeScript rules should AI-generated code follow? No eval or dynamic import from model output, no innerHTML with completions, strict schema validation at boundaries, explicit capability checks for tools, and no secrets in shared modules.
Next step
Pick the tool dispatcher in your codebase and add three things: a strict schema, a capability check and an audit line. That single file usually carries most of the risk in a TypeScript agent.
For a ready-made implementation, see resk-llm-ts, or read the LLM security pillar for the surrounding controls.