Is TypeScript All We Need for Application Security?
TypeScript, a superset of JavaScript, introduces static typing —a powerful feature that enhances code maintainability. But does it also add to our security expectations?
In this write-up, I want to provide observations as my own opinion about TypeScript security fallacies and how developers may have their expectations shattered due to surprising pitfalls.
What are the security promises of TypeScript? Let’s challenge the problem right off the start.
Suppose you rely on TypeScript for type security. Here’s a quick question—does this example help show how TypeScript keeps everything in check, only allowing what’s explicitly allowed?
class UserController {
public getUserHelloComponent: RequestHandler = async (
_req: Request<{},{},{}, {name?: string} >,
res: Response) => {
const userName = _req.query.name || "World";
if (!sanitizeXSS(userName)) {
return res.status(400).send("Bad input detected!");
}
const helloComponent = `<h1>Hello, ${userName}!</h1>`;
return res.send(helloComponent);
}
More importantly, I would like to ask: Do you perceive the adoption of TypeScript as helpful “development time” type checking, or do you view it as providing meaningful runtime security, too?
If you answered runtime security, we’re on a journey to learn about security controls and fallacies in TypeScript security.
Short TypeScript intro
TypeScript allows developers to catch potential errors early in the development cycle by explicitly defining data types for variables, functions, and objects. By adopting TypeScript, this proactive and additive approach to building applications on top of JavaScript significantly reduces the risk of runtime errors, vulnerabilities, and unexpected behavior.
This TypeScript opening focuses on minimizing runtime type errors. TypeScript is a “development time” tool by definition and actual tool use. Developers write their code in the TypeScript language and compile it into JavaScript. The TypeScript tooling ecosystem allows developers to ensure type safety through IDE intelligence and type-check code through a build and a continuous integration pipeline.
The following is an example of an API route handler definition in an Express application built with TypeScript:
class UserController {
public getUsers: RequestHandler = async (_req: Request, res: Response) => {
const filterQuery: string = _req.query.filter as string || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });
return handleServiceResponse(serviceResponse, res);
};
}
TypeScript and security
One type-related security issue in programming is known as type juggling. Type juggling occurs when JavaScript implicitly attempts to convert one type's values to another. You’ve probably seen this already in JavaScript through different “quirks” related puzzles.
For example:
// prints true
let x = 1;
console.log(x == "1")
The code above prints “true” because of the type coercion process. JavaScript first coerces the two operands to the same type and then compares them. This results in “1” being parsed as the numeric 1, and then compared, which evaluates to a truthy result.
By defining the expected types for variables, function parameters, and return values, TypeScript can catch many type-related errors during development before they can be exploited in production. But can you rely on it? Type juggling can lead to security vulnerabilities because types wouldn’t be strict at runtime, resulting in a security bypass of the type for which they were defined.
An Express and TypeScript application
To set the stage for the rest of the examples, we will use a Node.js backend API powered by the Express web framework and a TypeScript integration.
In particular, the Node.js server API application that is structured as follows:
src/
api/
user/
userController.ts
userRouter.ts
userModel.ts
userService.ts
userRepository.ts
server.ts
The Express server code loads the route definition, middleware, and other configurations as follows:
import { userRouter } from "@/api/user/userRouter";
// Middlewares
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors({ origin: env.CORS_ORIGIN, credentials: true }));
app.use(helmet());
app.use(rateLimiter);
// Request logging
app.use(requestLogger);
// Routes
app.use("/health-check", healthCheckRouter);
app.use("/users", userRouter);
We will rely on the userRouter
example throughout the TypeScript security examples discussed in this article.
TypeScript security bypass #1
We have a /users
endpoint that serves as a REST API endpoint. It allows users to search for themselves and, in particular, to pass a filter string to match the user's name.
The repository pattern that demonstrates how this filtering is done is as follows: It exposes a findAllAsync
method that receives an object with a filter
property.
export class UserRepository {
async findAllAsync({ filter }: { filter?: string } = {}): Promise<User[]> {
if (filter) {
return users.filter((user) => user.name.startsWith(filter));
}
return users;
}
}
To query the users, a GET HTTP request is sent:
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter=Al"| jq
How does the filter
query string flow from the route definition onto the repository layer? Let’s explore this pattern and how we can use TypeScript there.
The HTTP route definition is as follows: Defining the controller code for the /
route at the /users
prefix and matches all the HTTP requests for the GET verb:
import { userController } from "./userController";
export const userRouter: Router = express.Router();
userRouter.get("/", userController.getUsers);
Here is the Express controller code, which handles GET HTTP requests for this route:
import type { Request, RequestHandler, Response } from "express";
import { userService } from "@/api/user/userService";
import { handleServiceResponse } from "@/common/utils/httpHandlers";
class UserController {
public getUsers: RequestHandler = async (_req: Request, res: Response) => {
const filterQuery: any = _req.query.filter || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });
return handleServiceResponse(serviceResponse, res);
};
}
Like the userRepository
, the service definition the controller calls here for userService
has also been updated to accept an object in the first function argument with a filter
property and adhere to the filterQuery
type.
The HTTP response to our curl
request will be to list all users whose names start with the letter “Al”, so we get:
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter=Al"| jq
{
"success": true,
"message": "Users found",
"responseObject": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"age": 42,
"createdAt": "2025-01-13T10:51:37.118Z",
"updatedAt": "2025-01-18T10:51:37.118Z"
}
],
"statusCode": 200
}
So far, no surprises. But what if the GET request payload sent the filter
query string in a way that would be interpreted as an array and not a string?
Here is the exploit payload that would create the type of juggling security issue with our Node.js web application. All it has to do is change the query string field from filter=
to filter[]=
, and the same response is received:
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter[]=Al"| jq
{
"success": true,
"message": "Users found",
"responseObject": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"age": 42,
"createdAt": "2025-01-13T10:51:37.118Z",
"updatedAt": "2025-01-18T10:51:37.118Z"
}
],
"statusCode": 200
}
This works as before because JavaScript converts the array to its toString
definition, which results in the array's first element, the Al
string text. The same behavior as before is kept, even though the filter
variable is now treated as a string internally. Is this a bad thing? We’ll see.
You might have caught up on the horrendous TypeScript definition in the controller code at this point:
const filterQuery: any = _req.query.filter || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });
Using the dark magic of any
in TypeScript is an unconventional and frowned upon practice, perhaps only second to using eval in JavaScript. So, it's no wonder that type juggling exists. We allowed the filter
query string to match any type and then used it in the user service and repository.
Let’s do better.
TypeScript security bypass #2
We will avoid using the special any
TypeScript wildcard for types and instead strictly and explicitly define the filter
variable as a string type.
Let’s change the route implementation with a TypeScript string type:
class UserController {
public getUsers: RequestHandler = async (_req: Request, res: Response) => {
// we now define query as string
const filterQuery: string = _req.query.filter as string || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });
return handleServiceResponse(serviceResponse, res);
};
To ensure that all the types are correctly set and no type definition errors exist, we run the tsc
compiler and confirm there are indeed no errors:
$ npx tsc
What would happen if we sent the same type juggling HTTP payload that specifies the filter
as an array? Let’s see:
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter[]=Al"| jq
{
"success": true,
"message": "Users found",
"responseObject": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"age": 42,
"createdAt": "2025-01-13T10:51:37.118Z",
"updatedAt": "2025-01-18T10:51:37.118Z"
}
],
"statusCode": 200
}
Even though we explicitly defined the type of the filter
query string to be of string type and the TypeScript compiler executed with no errors, we could still perform a type juggling attack. Of course, this attack is merely a demonstration of capabilities and nothing harmful.
TypeScript security bypass #3
Let’s try a different TypeScript mechanism to declare types.
We'll follow a more conventional typing strategy instead of forcing TypeScript with an any
or as string
type declaration. We will define the interface for the expected query string schema and apply it to the Request
object type.
This GET endpoint will serve as a simplified alternative to React Server Components. It will receive a query string for the user’s name and provide an HTTP response that includes the HTML contents of the component that will be rendered to the DOM.
The following is the server-side component implementation:
interface UserComponentQueryString {
name?: string;
}
class UserController {
public getUserHelloComponent: RequestHandler = async (
_req: Request<{}, {}, {}, UserComponentQueryString>,
res: Response) => {
const userName = _req.query.name || "World";
if (!sanitizeXSS(userName)) {
return res.status(400).send("Bad input detected!");
}
const helloComponent = `<h1>Hello, ${userName}!</h1>`;
return res.send(helloComponent);
}
}
As you see, we’re typing the query string part of the Request type object, as expected and noted via the ReqQuery:
(alias) interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = qs.ParsedQs, Locals extends Record<string, any> = Record<string, any>>
import Request
Due to the glaring security issue concerning Cross-Site Scripting (XSS), we will also sanitize the user input, which is why we call a sanitizeXSS()
function. Here is the implementation:
function sanitizeXSS(name: string): boolean {
const disallowList = ["<", ">", "&", '"', "'", "/", "="];
return !disallowList.some((badInput) => name.includes(badInput));
}
So now we have established security guard rails via the following mechanisms:
Our interface explicitly maps the
Request<T>
generic. The fourth parameter,ReqQuery
, is set toUserComponentQueryString
, which ensures that the query string adheres to the structure defined in our interface. This means_req.query
will be an object with an optional name property of type string.We expect users may abuse this component creation logic and provide a
name
query string field with value such as<img src=x onError=alert(1) />
as a simplified example of how they would be able to perform harmful JavaScript operations on the client-side. To avoid that, we created asanitizeXSS
function, which detects if any dangerous strings are used for thename
property and deny it from being used in the generated HTML component.
Let’s attempt to send such a malicious payload request:
$ curl -G -X 'GET' -H 'accept: application/json' "http://localhost:8080/users/component" --data-urlencode "name=<img liran"
Bad input detected!
As you can see, we received the expected response per our logic to sanitize malicious characters from user input.
However, what happens if we apply the type juggling security issue we learned about and rename the name
field to name[]
? Will it pass in as an array type?
$ curl -G -X 'GET' -H 'accept: application/json' "http://localhost:8080/users/component" --data-urlencode "name[]=<img src=x onError=alert(1) />"
<h1>Hello, <img src=x onError=alert(1) />!</h1>
We bypassed our security guards! How did this happen?
The name[]
array is passed to the sanitizeXSS()
function, which attempts to run the includes()
function on the name passed to it via the disallowList
array matching predicate. However, name
is not a string but an array, so the predicate never returns a boolean true, and the Cross-Site Scripting sanitization logic fails.
TypeScript practices observations and learnings
Don’t use the
any
TypeScript type wildcard.Forcing types such as
_req.query.filter as string
is considered a bad practice, referred to by some as “lying to TypeScript.”You may follow the TypeScript Narrowing practice, often referred to as “type guard,” which performs runtime checks for expected types. For example:
if (typeof padding === "number") { … }
. However, this may become a regular practice throughout the codebase and degrade readability and maintainability.
Is TypeScript good for security? Yes, but it’s not what it was built for.
In my opinion, TypeScript is a development-time check, and while you can implement type guards, you can also do so with vanilla JavaScript. TypeScript needs a runtime companion in the form of schema validation, such as Zod and other libraries, to effectively enforce expected types, structure, and validation of data as it flows throughout the code.
Developer loved. Security trusted.
Snyk's dev-first tooling provides integrated and automated security that meets your governance and compliance needs.