Skip to content
Back to skills

Express

ASecurity

MUST USE when writing or reviewing Express routes, middleware, or error handling. Express 5 — automatic async error forwarding, the four-argument error handler, named wildcards, validation at the boundary, thin routes, and the security baseline every production app needs. Bundle covers tRPC v11 on Express.

  • 3 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added August 31, 2026
developmentrustgonodeexpressbackendsecurity

Works with

  • cli

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned September 21, 2026

npx -y skills add Spardutti/claude-skills --skill express --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Express?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Express
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/spardutti-express/badge)](https://www.skillsdirectory.com/skills/spardutti-express)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: express
category: Backend
description: "MUST USE when writing or reviewing Express routes, middleware, or error handling. Express 5 — automatic async error forwarding, the four-argument error handler, named wildcards, validation at the boundary, thin routes, and the security baseline every production app needs. Bundle covers tRPC v11 on Express."
tracks: express@5.2, @trpc/server@11.18
metadata:
  gate-paths: "**/*.ts, **/*.js"
---

# Express

Express 5 (5.2.x current; 5.1 is fine, Node 18+ required). If a file still uses
`express@4`, the async and wildcard rules below do **not** apply to it — check
`package.json` before assuming.

## Quick Reference — When to Load What

| Working on… | Read |
|---|---|
| tRPC routers, procedures, context, TRPCError, errorFormatter | TRPC.md |

## Project Structure

One folder per domain. Inside it, every module lives in the folder named for its
kind. The only file at a domain's root is `index.ts`, which exports its router.

```
src/
  expenses/
    routes/     admin.ts  public.ts          # one Router per audience, HTTP only
    services/   expenses.ts  expenses.test.ts
    schemas/    expense.ts                   # zod schemas + their z.infer types
    types/      expense.ts                   # types with no schema
    index.ts
  shared/       same kind folders, plus middleware/, for code 2+ domains use
  app.ts        mounts every domain router, then the error handler
  server.ts     listen + shutdown
```

```
BAD  src/routes/expenses.ts  src/services/expenses.ts     sorted by kind first
BAD  src/expenses/expenseSchemas.ts                        kind as a filename prefix
GOOD src/expenses/schemas/expense.ts
```

A test sits next to the file it tests and carries its name; a test of a whole
flow goes in `tests/`. Create a kind folder when its first file arrives, not before. Import another domain through its `index.ts` only.

## 1. Async Errors Forward Themselves — Stop Wrapping

Express 5 catches a rejected promise from a handler and sends it to the error
middleware. The try/catch-and-`next(err)` dance is Express 4 muscle memory.

```js
// BAD — Express 4 habit; the wrapper does nothing in 5
app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await findUser(req.params.id);
    res.json(user);
  } catch (err) {
    next(err);
  }
});

// GOOD — throw and let it land in the error handler
app.get("/users/:id", async (req, res) => {
  const user = await findUser(req.params.id);
  if (!user) throw new AppError("User not found", 404);
  res.json(user);
});
```

Catch only when you are going to **do** something — enrich the error, fall back,
release a resource. Catching to re-throw is noise.

## 2. The Error Handler Takes Four Arguments and Goes Last

Express identifies the error handler by **arity**. Three parameters and it is a
normal middleware that never sees an error.

```js
// BAD — silently never runs; Express sees a normal middleware
app.use((err, req, res) => { res.status(500).json({ error: err.message }); });

// GOOD — four parameters, registered after every route
app.use((err, req, res, next) => {
  const status = err.status ?? 500;
  if (status >= 500) req.log.error({ err }, "unhandled");
  res.status(status).json({
    error: status >= 500 ? "Internal Server Error" : err.message,
  });
});
```

Never send `err.message` for a 5xx — it leaks stack details and internal names.
Client errors carry a message you wrote; server errors get a generic one.

Order is load-bearing: routes → 404 handler → error handler. A `app.use` after
the error handler is unreachable.

## 3. One Error Type, Carrying Its Status

```js
// GOOD
export class AppError extends Error {
  constructor(message, status = 400, cause) {
    super(message, { cause });
    this.status = status;
  }
}
```

Anything thrown without a `status` is a bug, not a client error, and defaults to
500. That is the correct default — an unexpected error is not a 400.

## 4. Validate at the Boundary, Once

Parse the request into a typed value at the edge; below that, nothing revalidates.

```js
// BAD — validation scattered through the handler
app.post("/expenses", async (req, res) => {
  if (!req.body.amount) throw new AppError("amount required");
  if (typeof req.body.amount !== "number") throw new AppError("amount must be a number");
  // …six more lines before anything happens
});

// GOOD — one schema, one parse, a typed value after it
const CreateExpense = z.object({
  amount: z.number().positive(),
  note: z.string().max(200).optional(),
});

app.post("/expenses", async (req, res) => {
  const input = CreateExpense.parse(req.body);   // throws ZodError → 400
  res.status(201).json(await expenses.create(input));
});
```

Map `ZodError` to a 400 in the error handler, in one place:

```js
if (err instanceof z.ZodError) {
  // Zod 4: z.flattenError(err). Zod 3: err.flatten().
  return res.status(400).json({ error: "Invalid request", details: z.flattenError(err) });
}
```

**Check which Zod you are on.** Zod 4 replaced `message` / `invalid_type_error` /
`required_error` / `errorMap` with a single `error` param, and moved formatting to
`z.flattenError` / `z.treeifyError` / `z.prettifyError`. Zod 3 code compiles
against Zod 4 and then formats nothing.

## 5. `req.query` Is a Getter — You Cannot Assign It

Express 5 defines `req.query` with a getter and no setter. Assigning is silently
useless in loose mode and throws in strict mode.

```js
// BAD — no longer takes effect
req.query = { ...req.query, page: 1 };

// GOOD — parse into your own value
const query = ListQuery.parse(req.query);
```

Same for `req.params` shape changes. Treat both as read-only input.

## 6. Wildcards Must Be Named

`path-to-regexp` changed. A bare `*` throws at registration.

```js
// BAD — throws on startup in Express 5
app.get("/files/*", handler);

// GOOD — named, and req.params.path is an ARRAY of segments
app.get("/files/*path", (req, res) => {
  const relative = req.params.path.join("/");
});

// GOOD — to also match zero segments, brace the wildcard
app.get("/files{/*path}", handler);
```

Optional `:param?` is gone too — use `{/:param}`.

## 7. `res.status()` Validates Now

```js
res.status("404");   // TypeError — must be an integer
res.status(99);      // RangeError — must be 100–999
res.status(err.status ?? 500);   // GOOD — never pass through an unchecked value
```

A status computed from user input or an upstream response can crash the handler.
Default it before it reaches `res.status`.

## 8. Routes Stay Thin

A route reads input, calls one thing, and shapes the response. Business logic
lives in a module that has never heard of HTTP — that is what makes it testable
without a server and reusable from a job or a CLI.

```js
// BAD — the route IS the feature
app.post("/expenses", async (req, res) => {
  const input = CreateExpense.parse(req.body);
  const account = await db.account.findUnique({ where: { id: input.accountId } });
  if (!account) throw new AppError("No such account", 404);
  if (account.balance < input.amount) throw new AppError("Insufficient funds", 409);
  // …30 more lines
});

// GOOD — HTTP at the edge, the decision underneath
app.post("/expenses", async (req, res) => {
  const expense = await expenses.create(CreateExpense.parse(req.body));
  res.status(201).json(expense);
});
```

## 9. The Security Baseline

Not optional, and not alternatives to each other — they solve different problems.

```js
app.use(helmet());                                    // security headers
app.use(cors({ origin: ALLOWED_ORIGINS }));           // never `origin: true` in prod
app.use(rateLimit({ windowMs: 60_000, limit: 100 })); // brute force, abuse
app.use(express.json({ limit: "100kb" }));            // an unbounded body is a DoS
```

Also: `app.set("trust proxy", 1)` behind a load balancer, or the rate limiter
sees one IP for everyone and `req.ip` is wrong.

## 10. Shut Down Cleanly

```js
// GOOD
const server = app.listen(PORT);
for (const signal of ["SIGTERM", "SIGINT"]) {
  process.on(signal, () => {
    server.close(async () => {
      await db.end();
      process.exit(0);
    });
  });
}
```

Without this a deploy kills in-flight requests mid-write.

## Rules

1. **Never wrap an async handler in try/catch to re-throw** — Express 5 forwards rejections itself.
2. **Always give the error handler four parameters and register it last** — arity is how Express finds it.
3. **Never return `err.message` on a 5xx** — a generic message for server errors, your own text for client errors.
4. **Always validate once at the boundary** and pass a typed value down; nothing below revalidates.
5. **Always name a wildcard** (`*path`) and brace it (`{/*path}`) to match zero segments.
6. **Never assign to `req.query`** — it is a getter in Express 5.
7. **Always default a status before `res.status()`** — it throws on a non-integer or out-of-range code.
8. **Always keep business logic out of the route** — a route reads input, calls one thing, shapes a response.
9. **Always run helmet, CORS with an explicit origin, a rate limiter, and a body limit** in production.
10. **Always close the server and drain connections on SIGTERM.**
11. **Always put a module in its domain's kind folder** (`routes/ services/ schemas/ types/`) — never a top-level `src/routes/`, never a kind as a filename prefix.

## Reference Files

- **TRPC.md** — read before adding any endpoint to a repo that already has a tRPC router — a new route belongs there, not in a fresh REST handler — and when working on tRPC routers, procedures, or the Express adapter. Covers `createExpressMiddleware` and typed context, `protectedProcedure` middleware that narrows the context type, why a plain `Error` becomes a 500 and `TRPCError` does not, the error-code-to-HTTP mapping, `errorFormatter` for field-level Zod errors (and the Zod 3 → 4 difference), `maxBodySize`, and where tRPC and REST routes coexist in one app.

Files in this skill

  • SKILL.md8.4 KB
  • TRPC.md7 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.