TypeScript cannot sanitize HTML, prevent SQL injection, or authorize requests. Those controls must run at runtime.
It can reduce mistakes inside an application: returning database rows from an API, mixing tenant and user IDs, treating unvalidated JSON as trusted input, or passing vague permission booleans through several layers. The useful pattern is to model trust boundaries explicitly, then keep runtime checks close to the conversion point.
What TypeScript can and cannot do for security
A type system helps when a security property concerns the shape, origin, or permitted flow of values inside an application. It can distinguish a public API response from a database row. It can reject a ProjectId where a UserId is required. It can require a caller to provide a verified access capability before invoking a sensitive operation.
This moves some failures from production behavior to compiler feedback. A refactor that returns passwordHash can fail review because the endpoint must return a public DTO. A tenant-scoped repository method can reject arguments from the wrong domain before tests run.
TypeScript is erased at runtime. An attacker does not send a server a TenantId; they send JSON, headers, cookies, URLs, and request bodies. A cast such as value as TenantId is a developer assertion, not proof that the value is valid.
Keep the responsibility of each control clear:
Control
Problem it addresses
Static types
Accidental misuse of values in application code
Runtime schemas
Malformed or untrusted external data
Authentication and authorization
Whether a caller may perform an action
Parameterized SQL
SQL injection through query data
Output encoding and sanitization
Browser injection risks such as XSS
Tests, monitoring, and review
Regressions outside compiler coverage
Types are one layer. They work when they describe boundaries enforced elsewhere in the system.
Model boundaries instead of passing persistence objects around
A common source of accidental exposure is treating an ORM model or database row as the application’s universal user object. A handler loads a user and returns it. Another handler sends the same object to a logger. Later, a migration adds mfaSecret, passwordHash, a reset token, support notes, or billing metadata.
Every serialization site now needs to remember which fields to remove. This deny-list approach fails when the schema changes faster than callers are audited.
Separate representations based on where data may travel:
Persistence model: Matches stored data and may include sensitive fields.
Domain model: Represents business concepts and operations.
Request DTO: Represents data accepted by a specific endpoint.
Response DTO: Represents data intentionally exposed by an endpoint.
Log event: Contains fields approved for observability.
Not every entity needs each representation. The split has the most value for users, credentials, payments, tenancy, permissions, and objects with internal-only state.
This database row must never be emitted directly at an HTTP boundary:
type UserRow = {
id: string;
email: string;
passwordHash: string;
mfaSecret: string | null;
internalNotes: string | null;
passwordResetToken: string | null;
};
Enter fullscreen mode Exit fullscreen mode
A response DTO states what the endpoint may reveal:
type PublicUserDto = {
id: string;
email: string;
};
function toPublicUser(user: UserRow): PublicUserDto {
return {
id: user.id,
email: user.email,
};
}
Enter fullscreen mode Exit fullscreen mode
The important decision is the allow-list. Adding a column to UserRow does not add it to PublicUserDto. A new sensitive field remains private until someone intentionally exposes it.
async function getCurrentUser(req: Request, res: Response) {
const user = await users.findById(req.auth.userId);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
return res.json(toPublicUser(user));
}
Enter fullscreen mode Exit fullscreen mode
The mapper is a review point. Requirements to add displayName or an avatar URL appear as a small, visible DTO change.
Apply the same rule to logs
Logging arbitrary objects is another data-exposure boundary. This code is easy to add while debugging and hard to audit later:
logger.info({ user, requestBody: req.body }, "login attempt");
Enter fullscreen mode Exit fullscreen mode
It may record credentials, tokens, personal data, or columns added by a later migration. Prefer event-shaped logs:
type LoginAudit = {
userId: string;
outcome: "success" | "failure";
};
const event = {
userId: user.id,
outcome: "success",
} satisfies LoginAudit;
logger.info(event, "login attempt");
Enter fullscreen mode Exit fullscreen mode
For failures, log a stable error code instead of the raw request body. If support workflows need selected input, create a redaction function and test that sensitive fields never appear.
type LoginFailureAudit = {
emailDomain: string | null;
reason: "invalid_credentials" | "account_locked";
};
function toLoginFailureAudit(
email: string,
reason: LoginFailureAudit["reason"],
): LoginFailureAudit {
const [, domain] = email.split("@");
return {
emailDomain: domain ?? null,
reason,
};
}
Enter fullscreen mode Exit fullscreen mode
Logger redaction settings and ORM serialization hooks are useful backup controls. The log call site should still show the intended data.
Use DTOs and allow-list serialization
Explicit DTOs prevent another problem: endpoint contracts changing because internal models change. ORM relations, joins, virtual fields, and serializer defaults can expose fields the endpoint author did not intend to publish.
Keep response mapping near the API layer. Repository methods should return information needed by the domain layer. Controllers or presentation modules should decide the public contract.
type ProjectRow = {
id: string;
tenantId: string;
name: string;
visibility: "private" | "team";
archivedAt: Date | null;
billingAccountId: string;
};
type ProjectDto = {
id: string;
name: string;
visibility: "private" | "team";
};
function toProjectDto(project: ProjectRow): ProjectDto {
return {
id: project.id,
name: project.name,
visibility: project.visibility,
};
}
Enter fullscreen mode Exit fullscreen mode
This repetition records a policy decision. tenantId, archivedAt, and billingAccountId are not part of this API contract.
For larger APIs, use a presentation layer or serializer module. Avoid generic pick() helpers that scatter unreviewed field lists across the codebase. Named mappers are easier to search, test, and review.
Integration tests should verify real JSON responses, not only TypeScript assignments. Types cannot protect an endpoint that bypasses the mapper or an ORM plugin that serializes a model directly.
it("does not expose credential fields", async () => {
const response = await request(app)
.get("/v1/me")
.set("authorization", userToken);
expect(response.body).toEqual({
id: expect.any(String),
email: "[email protected]",
});
expect(response.body).not.toHaveProperty("passwordHash");
expect(response.body).not.toHaveProperty("mfaSecret");
});
Enter fullscreen mode Exit fullscreen mode
Make identifiers and authority explicit
A string does not state whether it identifies a tenant, user, project, invoice, or external provider account. Plain strings are easy to mix, especially in multi-tenant services where a missing tenant constraint can expose another customer’s data.
Branded types add nominal meaning to TypeScript’s structural type system:
declare const tenantBrand: unique symbol;
declare const userBrand: unique symbol;
declare const projectBrand: unique symbol;
type TenantId = string & { readonly [tenantBrand]: true };
type UserId = string & { readonly [userBrand]: true };
type ProjectId = string & { readonly [projectBrand]: true };
Enter fullscreen mode Exit fullscreen mode
A repository signature can then state its scope:
async function loadUser(tenantId: TenantId, userId: UserId) {
return db.query(
"SELECT id, email FROM users WHERE tenant_id = $1 AND id = $2",
[tenantId, userId],
);
}
Enter fullscreen mode Exit fullscreen mode
Calling loadUser(tenantId, projectId) is a type error. This does not prove the query is correct, but it prevents swapped-argument bugs that can look valid in review.
Construct brands only after validation
Do not export a generic conversion helper that converts any string into any brand. It defeats the purpose. Create domain values after validating data at a boundary.
function asTenantId(value: string): TenantId {
if (!/^[0-9a-f-]{36}$/i.test(value)) {
throw new Error("Invalid tenant ID");
}
return value as TenantId;
}
Enter fullscreen mode Exit fullscreen mode
In production, prefer a UUID parser or the identifier format used by the system instead of maintaining a broad regular expression. One small module should own the cast and validate before casting.
Brands provide no runtime protection. Database constraints, query predicates, row-level security where appropriate, and authorization checks remain necessary.
Model verified authority as a capability
A boolean such as canEdit is easy to pass through several layers, disconnect from its resource, or accidentally derive from untrusted input. A capability type keeps the checked user and resource together.
Use a private brand so modules importing ProjectEditor cannot construct it with a normal object literal.
// project-access.ts
const projectEditorBrand: unique symbol = Symbol("projectEditor");
export type ProjectEditor = {
readonly userId: UserId;
readonly projectId: ProjectId;
readonly [projectEditorBrand]: true;
};
export async function requireProjectEditor(
userId: UserId,
projectId: ProjectId,
): Promise<ProjectEditor> {
const membership = await memberships.find(userId, projectId);
if (!membership || membership.role !== "editor") {
throw new ForbiddenError();
}
return {
userId,
projectId,
[projectEditorBrand]: true,
};
}
export async function renameProject(access: ProjectEditor, name: string) {
return projects.rename(access.projectId, name);
}
Enter fullscreen mode Exit fullscreen mode
The request handler first authenticates the caller, then loads trusted membership data, then obtains the capability:
const access = await requireProjectEditor(req.auth.userId, projectId);
await renameProject(access, input.name);
Enter fullscreen mode Exit fullscreen mode
Keeping the brand private prevents importing modules from constructing this value with an object literal. It does not prevent unsafe casts. Authorization still depends on trusted runtime checks.
Every relevant request must verify identity, tenant membership, ownership, and current policy. A stale role cache, an incorrectly scoped lookup, or a cast can still create an authorization flaw.
Validate data before creating domain values
External values are unknown until checked. This includes more than HTTP request bodies:
URL parameters and query strings
Headers, cookies, and bearer-token claims
Environment variables
Webhook payloads
Queue messages and event streams
Imported CSV files
Third-party API responses
Database data when schemas or migrations are not fully trusted
TypeScript interfaces do not validate any of these values. A request annotated as CreateProjectInput still arrives as bytes over the network.
Use a runtime schema at the boundary, then infer the TypeScript type from that schema. This avoids maintaining two sources of truth.
import { z } from "zod";
const CreateProjectSchema = z.object({
name: z.string().trim().min(1).max(120),
visibility: z.enum(["private", "team"]),
}).strict();
type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
function parseCreateProject(body: unknown): CreateProjectInput {
return CreateProjectSchema.parse(body);
}
Enter fullscreen mode Exit fullscreen mode
.strict() is a policy choice. Rejecting unknown keys can catch client bugs and prevent accidental acceptance of fields such as ownerId or role. Some public APIs intentionally ignore unknown fields for forward compatibility. Choose and document the behavior.
Schema validation checks shape and local constraints. It does not enforce business rules that depend on current state. A valid project name may already be in use. A valid projectId may belong to another tenant. A valid visibility value may be unavailable to the caller’s plan or role.
A safe request flow is usually:
Authenticate the request.
Parse and validate untrusted syntax and shape.
Convert validated primitives into domain values where needed.
Load current resource and membership state.
Authorize the requested operation.
Apply business rules and write through scoped data access.
Set resource limits separately. A schema that allows a 120-character string does not limit the HTTP request body. Configure body-size limits, upload limits, timeouts, queue payload limits, and rate limits at the relevant layers.
Keep SQL parameterized
Types can clarify repository inputs. They do not make string interpolation safe. SQL injection prevention depends on a database driver or query builder that sends data separately from SQL syntax.
Use parameter placeholders for values:
const result = await db.query(
"SELECT id, email FROM users WHERE tenant_id = $1 AND id = $2",
[tenantId, userId],
);
Enter fullscreen mode Exit fullscreen mode
Do not interpolate values into query text, even if the values have branded types or passed a schema:
// Unsafe. The value becomes SQL source text.
await db.query(`SELECT * FROM users WHERE id = '${userId}'`);
Enter fullscreen mode Exit fullscreen mode
A UUID validation rule may reduce risk in one path. It does not make interpolation a safe convention. Validation rules change, other inputs may have weaker constraints, and escaping varies by database and driver.
Dynamic identifiers need an allow-list
Placeholders generally cannot represent SQL identifiers or keywords. If an endpoint supports sorting by selected columns, construct that fragment only from source-owned literals.
const sortColumns = {
createdAt: "created_at",
name: "name",
} as const;
type SortKey = keyof typeof sortColumns;
function parseSortKey(value: unknown): SortKey {
if (value === "createdAt" || value === "name") {
return value;
}
return "createdAt";
}
const sortKey = parseSortKey(req.query.sort);
const sortColumn = sortColumns[sortKey];
const result = await db.query(
`SELECT id, name FROM projects WHERE tenant_id = $1 ORDER BY ${sortColumn} ASC`,
[tenantId],
);
Enter fullscreen mode Exit fullscreen mode
The request controls a known key, not arbitrary SQL. Apply the same approach to sort direction, table selection, and other dynamic SQL fragments.
Treat HTML as a runtime trust boundary
Most rendering should use framework escaping. React escapes strings rendered in JSX by default:
<div>{comment.body}</div>
Enter fullscreen mode Exit fullscreen mode
The risk appears when an application intentionally renders HTML, such as rich-text content or imported documentation. Raw user HTML must not reach dangerouslySetInnerHTML without a reviewed sanitization step.
A brand makes the distinction visible:
declare const sanitizedHtmlBrand: unique symbol;
type SanitizedHtml = string & {
readonly [sanitizedHtmlBrand]: true;
};
Enter fullscreen mode Exit fullscreen mode
The brand alone has no security value:
const unsafe = input as SanitizedHtml;
Enter fullscreen mode Exit fullscreen mode
That cast only changes the compile-time label. The constructor must run a maintained sanitizer with an application-specific policy.
For Node.js or server-side rendering, DOMPurify needs a DOM implementation. The following setup uses JSDOM:
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
function sanitizeHtml(input: string): SanitizedHtml {
const clean = DOMPurify.sanitize(input, {
ALLOWED_TAGS: ["p", "a", "strong", "em", "ul", "ol", "li", "code", "pre"],
ALLOWED_ATTR: ["href"],
});
return clean as SanitizedHtml;
}
Enter fullscreen mode Exit fullscreen mode
The rendering component can require sanitized content:
function RichText({ html }: { html: SanitizedHtml }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
Enter fullscreen mode Exit fullscreen mode
Keep the sanitizer factory server-only when using SSR. Browser bundles can initialize DOMPurify with the browser window object instead.
Sanitizer settings are security-sensitive code. Review allowed tags, attributes, URL protocols, CSS handling, embedded content, and server-rendering behavior. Keep DOMPurify and JSDOM updated. If the product does not need arbitrary HTML, use Markdown with a restricted renderer or a structured rich-text format.
Adopt the pattern without type theater
Security-focused types lose value when every escape hatch is open. any, broad as casts, non-null assertions, and generic conversion helpers can erase the protections. Some are necessary at isolated interoperability boundaries. They should not be the usual way to satisfy the compiler.
Start at boundaries with direct security consequences:
HTTP responses and logs: Add response DTOs and named log events for user, payment, admin, and tenancy data.
Tenant-scoped access: Introduce distinct identifiers where swapped values or missing scope could expose another customer’s data.
External inputs: Parse request bodies, webhooks, queues, and environment values with runtime schemas.
SQL access: Standardize parameterized database APIs and fixed allow-lists for dynamic identifiers.
Rich-text rendering: Keep raw and sanitized HTML separate, with one narrow sanitization module.
Do not brand every string. A CurrencyCode, TenantId, or SanitizedHtml type justifies its maintenance cost when mixing it with another value can create a real bug. A type used everywhere but protecting nothing adds friction without improving review quality.
Enforce conventions with tooling and tests
Compiler settings help expose unsafe assumptions:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true
}
}
Enter fullscreen mode Exit fullscreen mode
These options are not security controls. They reduce ambiguity around undefined, optional fields, and inheritance, making boundary code easier to reason about.
Use linting and review rules for dangerous escape hatches. Examples include requiring justification for as, prohibiting any outside approved modules, and flagging direct model serialization in controllers. Keep exceptions narrow and documented.
Test what types cannot prove:
An authenticated user cannot read or modify another tenant’s resource.
Sensitive columns do not appear in real HTTP responses or logs.
Webhook verification occurs before payload use.
SQL repository methods retain tenant predicates.
Sanitized rich text rejects unsafe URLs and event handlers.
Role changes and revoked memberships take effect as expected.
Integration tests should exercise real middleware, serializers, and database paths. Mapper unit tests help, but integration tests catch endpoints that bypass the mapper.
Key takeaways
TypeScript improves application security when it represents meaningful boundaries.
Return explicit response DTOs rather than persistence objects. Allow-list fields that may leave the service.
Create dedicated log events and redaction functions. Do not log arbitrary models or request bodies.
Use branded identifiers for values that must not be mixed, especially in tenant-scoped code.
Model verified authority with capability types, but check authorization from trusted state for every relevant request.
Parse external values at runtime before treating them as domain input.
Keep SQL parameterized. Use fixed source-owned allow-lists for dynamic identifiers.
Escape HTML by default. Sanitize only when rendering HTML is required.
Back type design with integration tests, database constraints, dependency maintenance, monitoring, and security review.
Explicit DTOs, scoped identifiers, narrow authority-bearing APIs, and visible trust conversions make unsafe flows easier to spot during implementation and review. They do not replace runtime defenses. They reduce the chance that ordinary application mistakes bypass those defenses.
답글 남기기