# Security review for TypeScript: what types do and do not buy you

> Source: https://learn-typescript.org/review/security/
> Part of Learn TypeScript, free to read.

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 `AuthenticatedUser` cannot be called with an anonymous one.
- They make **unit confusion impossible** — a `UserId` cannot be passed where an `OrgId` is expected.

Everything in [the JavaScript security checklist](https://learn-javascript.org/review/security/) 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

```ts
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.

```ts
// 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 parse
```

:::danger `as` at a trust boundary is a security defect, not a style issue
Treat it that way in review. The grep is cheap:

```bash
git diff | grep -nE ' as [A-Z]| as any|<[A-Z][A-Za-z]*>\(|!\.|@ts-ignore'
```

Every hit on data that came from a network, a database, a file, an environment variable or a message queue needs to become a parse.
:::

Ban it mechanically where you can:

```js eslint.config.js
"@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

```ts
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

```ts
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](https://learn-javascript.org/review/security/).

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

```ts
type Session =
  | { state: "anonymous" }
  | { state: "authenticated"; user: AuthenticatedUser }
  | { state: "expired"; at: Date };

// reading session.user in the anonymous branch does not compile
```

Generated 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:

```ts src/env.ts
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 2am
```

An 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](/review/dependencies/).

### `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

```ts
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

```bash
# 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=dev
```

:::verdict The 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.
