Security review for TypeScript: what types do and do not buy you
Types are erased at runtime, so an attacker never sees them. The security value of TypeScript is real but indirect — and generated code reaches for the escape hatches at exactly the wrong moments.
Start with the thing that causes the most trouble: TypeScript provides no runtime security whatsoever. Types are erased at compile time. An attacker sends whatever they like, and your annotations are not there to stop it.
What types do buy you is real, and it is worth being precise about, because a false sense of safety is worse than none:
- They make trust boundaries explicit — if external data can only enter as
unknown, you cannot forget to validate it. - They make authorisation state visible in signatures — a function that requires an
AuthenticatedUsercannot be called with an anonymous one. - They make unit confusion impossible — a
UserIdcannot be passed where anOrgIdis expected.
Everything in the JavaScript security checklist applies unchanged — prototype pollution, ReDoS, XSS, SSRF, path traversal. This page is what is specific to TypeScript.
The security bug that looks like a type annotation#
const user = await res.json() as User;
const body = JSON.parse(raw) as CreateOrderDto;
const claims = jwt.decode(token) as TokenClaims;Every one of those compiles. Every one is a lie: as is an assertion, nothing runs, and from that line onward the compiler will confidently tell you fields exist that may not, and cannot exist that may.
The third is the worst. jwt.decode does not verify the signature — so an attacker-supplied token becomes a fully typed TokenClaims object, and every downstream check reads as safe.
// parse, do not assert
const Claims = z.object({ sub: z.string(), role: z.enum(["user", "admin"]), exp: z.number() });
const raw = jwt.verify(token, secret, { algorithms: ["HS256"] }); // verify first
const claims = Claims.parse(raw); // then parseBan it mechanically where you can:
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-argument": "error",
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/ban-ts-comment": ["error", { "ts-ignore": true }],The four no-unsafe-* rules are the ones people miss. They catch any arriving from an untyped dependency and flowing into your code, which is how most any gets in — not from someone typing it.
Use types to make authorisation hard to skip#
This is where TypeScript genuinely earns its place in security, and it is underused.
Branded identifiers#
type UserId = Brand<string, "UserId">;
type OrgId = Brand<string, "OrgId">;
function invoicesFor(org: OrgId): Promise<Invoice[]>;
invoicesFor(userId); // compile error. a whole class of IDOR, gone.Passing the wrong identifier is one of the more common routes to a broken access control bug, and branding removes it entirely at zero runtime cost.
Make "authorised" a type#
declare const authorised: unique symbol;
type Authorised<T> = T & { readonly [authorised]: true };
/** The ONLY way to produce an Authorised<Invoice>. */
export async function loadInvoiceFor(
id: InvoiceId, user: AuthenticatedUser,
): Promise<Authorised<Invoice> | null> {
const inv = await repo.byId(id);
return inv && inv.ownerId === user.id ? (inv as Authorised<Invoice>) : null;
}
export function renderInvoice(inv: Authorised<Invoice>): string { /* … */ }Now a handler that fetches an invoice without checking ownership cannot pass it to the renderer. The compiler enforces the check that is otherwise the most common real vulnerability in web code.
This is one of the few places where a type-level trick pays for its cleverness. Keep the as inside the one authorising function, and ban it everywhere else.
Model auth state as a union#
type Session =
| { state: "anonymous" }
| { state: "authenticated"; user: AuthenticatedUser }
| { state: "expired"; at: Date };
// reading session.user in the anonymous branch does not compileGenerated code models this as { user?: User }, and then if (session.user) checks appear in some paths and not others.
What types cannot see#
The list worth keeping in mind, because each one is a place people assume the compiler helped and it did not:
| Risk | Why types miss it | |
|---|---|---|
| Prototype pollution | __proto__ is a runtime key; no type involved | |
| ReDoS | a RegExp is a RegExp regardless of what it does | |
XSS via dangerouslySetInnerHTML | it takes a string, and that is a valid string | |
| SQL injection | a template literal is a string | |
| SSRF | a URL is a string | |
| Timing attacks | === is well-typed | |
| Secrets in logs | a typed object logged wholesale still contains the secret | |
| Environment variables | process.env.X is `string \ | undefined`, and its value is unvalidated |
That last one is worth a fix, because it is cheap:
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().int().positive().default(3000),
});
export const env = Env.parse(process.env); // fails at boot, not at 2amAn application that refuses to start on a missing or weak secret is much better than one that starts and fails on the first request that needs it.
Type-level risks that are actually TypeScript's own#
@types packages are a supply-chain surface#
A @types/* package is executable-adjacent: it is fetched from npm like anything else, and while type definitions do not run, a compromised one can be replaced by a package that does have an install script. It also runs through your build. Treat @types with the same scrutiny as runtime dependencies — see dependency hygiene.
skipLibCheck hides real problems#
Nearly every project sets skipLibCheck: true because it makes builds faster and silences errors in third-party definitions. That is a reasonable trade, but be aware you have turned off checking of the type definitions you are trusting.
Declaration merging and module augmentation#
declare global {
namespace Express {
interface Request { user?: AuthenticatedUser } // now optional everywhere
}
}Widely used and worth understanding: making user optional on every request means every access needs a check, and generated code will reach for req.user! to silence it. Prefer passing the authenticated user explicitly, or a middleware that narrows the type.
Source maps in production#
Shipping .map files exposes your original source, including comments and internal structure. Generate them, upload them to your error tracker, and do not serve them publicly.
The review#
# the type-specific half
git diff | grep -nE ' as [A-Z]| as any|: any|@ts-ignore|!\.|!\)'
git diff | grep -nE 'jwt\.decode|skipLibCheck|process\.env\.'
npx tsc --noEmit && npx eslint .
# everything from the JavaScript checklist still applies
git diff | grep -nE "eval\(|innerHTML|dangerouslySetInnerHTML|\bexec\(|__proto__"
npm audit --omit=devThe one thing to take away
TypeScript's security contribution is at the boundary: unknown in, schema parse, typed value out. Get that right and the type system genuinely prevents whole categories of bug. Reach for as at that same boundary and you have written a security defect that looks exactly like documentation.
Common questions#
Does TypeScript make my application more secure?#
Indirectly and meaningfully, if you use it to enforce boundaries — branded ids, authorised types, parsed environment, unknown at every entry point. It does nothing at runtime, so it cannot stop an injection or a pollution attack. The honest framing: it prevents mistakes, not attacks.
Is banning as entirely realistic?#
More often than people expect. Legitimate uses — narrowing a DOM element, as const, a well-understood cast inside a single authorising function — are rare enough that requiring an explicit eslint-disable comment is reasonable. The comment then becomes a useful review signal.
Should validation happen at every layer or just the edge?#
At the edge, thoroughly, and then trust the types inside. Validating repeatedly is a cost with no benefit and it dilutes where the real boundary is. The discipline is that there is exactly one place external data becomes typed, and it is a parse.
What about validating data from my own database?#
Depends who writes to it. If other services or older versions of your code write rows, the database is an external boundary and deserves a parse — schemas drift and nullable columns appear. If your application is the only writer and migrations are controlled, trusting the ORM's types is reasonable.
Get the TypeScript agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for TypeScript. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.