Type-Driven Security: Reducing OWASP Risk With Strong Types

Security failures often begin at ordinary boundaries: a database record is returned directly from an API, an identifier from one domain is accepted in another, untrusted input is treated as valid, or a query is assembled as a string. Framework defaults, reviews, tests, and monitoring remain essential-but a well-designed type system can make some of these mistakes harder to write and easier to spot.
Scope matters: types do not stop or eliminate OWASP vulnerabilities on their own. They cannot prove that an authenticated user is authorized, make hostile network input trustworthy, sanitize HTML, or replace parameterized database APIs. They are one defense-in-depth layer that can encode security-relevant intent in application code.
What types are good at
Types are especially useful when a security property is about which values may cross a boundary. They can distinguish public response data from persistence models, prevent accidental mixing of identifiers, require explicit construction of sensitive values, and make unsafe APIs inconvenient to call.
That shifts some failures from production behavior to compiler feedback. It does not eliminate the need for runtime controls at trust boundaries.
Use DTOs and controlled serialization to prevent accidental exposure
Database models commonly contain fields that should never reach clients or logs. Returning them directly makes every caller responsible for remembering what to remove. Prefer an explicit response DTO and a single mapping function.
type UserRow = {
id: string;
email: string;
passwordHash: string;
mfaSecret: string | null;
internalNotes: string | null;
};
type PublicUserDto = {
id: string;
email: string;
};
function toPublicUser(user: UserRow): PublicUserDto {
return { id: user.id, email: user.email };
}
// res.json(toPublicUser(user));
// Never serialize UserRow directly at an HTTP boundary.
The allow-list is the important part: adding a sensitive database column does not automatically expose it. Apply the same idea to logging. Create log-specific DTOs or redaction helpers rather than passing arbitrary objects to JSON.stringify.
type LoginAudit = {
userId: string;
outcome: "success" | "failure";
};
logger.info({ userId: user.id, outcome: "success" } satisfies LoginAudit);
Make identifier and authorization context explicit
Two strings may have very different authority. A plain string makes it easy to pass a tenant ID where an account ID is expected. Branded types can prevent accidental mixing inside TypeScript code.
declare const tenantBrand: unique symbol;
declare const userBrand: unique symbol;
type TenantId = string & { readonly [tenantBrand]: true };
type UserId = string & { readonly [userBrand]: true };
function loadUser(tenantId: TenantId, userId: UserId) {
// Query constrained by tenantId and userId.
}
Authorization can be represented explicitly too, rather than relying on a vague boolean passed through several layers.
type Viewer = { kind: "viewer"; userId: UserId };
type ProjectEditor = { kind: "project-editor"; userId: UserId; projectId: string };
type ProjectAccess = Viewer | ProjectEditor;
function renameProject(access: ProjectEditor, name: string) {
// Only a caller that established editor access can call this API.
}
This improves API design, but it is not authorization by itself. The server must still authenticate the request and verify the tenant, project membership, ownership, and policy against trusted data on every relevant request. Brands disappear at runtime, so external values must be validated before they are cast or constructed.
Keep SQL parameterized-types are not a SQL-injection fix
Parameterized queries are the primary control against SQL injection. Types can help shape query inputs, but they do not make string interpolation safe.
// Good: data remains a parameter, not SQL syntax.
const result = await db.query(
"SELECT id, email FROM users WHERE tenant_id = $1 AND id = $2",
[tenantId, userId]
);
// Do not do this-even if userId has a TypeScript type:
// db.query(`SELECT * FROM users WHERE id = '${userId}'`);
For dynamic identifiers such as sort columns, use a fixed allow-list and construct only from known literals. Parameter placeholders generally cannot substitute SQL identifiers or keywords.
HTML requires a trusted/sanitized boundary
Encoding and sanitization are runtime problems because HTML often originates outside the type checker. Treat raw user HTML and sanitized HTML as distinct concepts, but do not confuse a type assertion with sanitization.
declare const sanitizedHtmlBrand: unique symbol;
type SanitizedHtml = string & { readonly [sanitizedHtmlBrand]: true };
function sanitizeHtml(input: string): SanitizedHtml {
// Use a maintained sanitizer with an application-specific allow-list.
return DOMPurify.sanitize(input) as SanitizedHtml;
}
function renderRichText(html: SanitizedHtml) {
return { __html: html };
}
Use framework escaping by default. Only render HTML through a reviewed sanitizer, and account for URL policies, rich-text features, server-side rendering, and sanitizer updates. A SanitizedHtml type is useful only if its constructor is tightly controlled.
Validate runtime input before it becomes a domain type
TypeScript types describe developer intent but are erased at runtime. JSON payloads, environment variables, queue messages, database rows, and third-party webhooks are all untrusted until validated.
import { z } from "zod";
const CreateProject = z.object({
name: z.string().trim().min(1).max(120),
visibility: z.enum(["private", "team"]),
});
type CreateProjectInput = z.infer<typeof CreateProject>;
function parseCreateProject(body: unknown): CreateProjectInput {
return CreateProject.parse(body);
}
Validation should happen at the edge, with clear error handling, size limits where appropriate, and authorization checks after identity and relevant resource state are known. Schema validation libraries complement static types; they do not replace business-rule validation.
Trade-offs and failure modes
- More types and mappers: DTOs, schemas, and branded values add code and maintenance.
- Unsafe escape hatches: broad
ascasts,any, and non-null assertions can bypass the guarantees. Restrict and review them. - False confidence: compile-time correctness says nothing about deployment configuration, access control policy, dependencies, or runtime input unless those are separately handled.
- Boundary drift: generated clients, ORMs, and serializers may bypass carefully designed domain types. Test the actual HTTP and database behavior.
Adoption checklist
- Identify high-risk boundaries: HTTP responses, logs, SQL access, webhooks, queues, and rich-text rendering.
- Introduce explicit public DTOs and allow-list serializers for sensitive entities.
- Use distinct ID and authorization-context types where mix-ups could cross tenant or resource boundaries.
- Require parameterized SQL APIs; allow-list dynamic identifiers.
- Validate all external data at runtime with schemas before creating domain values.
- Keep raw HTML separate from sanitized/trusted HTML, and use a maintained sanitizer.
- Back the design with authorization tests, integration tests, dependency updates, monitoring, and security review.
Conclusion
Strong types are most valuable when they make the secure path the natural path: explicit data transfer objects, constrained identifiers, narrow authority-bearing APIs, and visible trust boundaries. Used alongside runtime validation, parameterized queries, output encoding and sanitization, authorization enforcement, and operational controls, they can reduce the chance that common security mistakes reach production. They are not a substitute for those controls-and they should never be presented as one.

