Input Validation skill for Codex

Parse untrusted data once, at the edge. Codex finds every trust boundary in the code (bodies, query strings, env vars, queue messages, third-party responses) and parses each into a typed value once with the project's schema library. Schemas allowlist fields to stop mass assignment, bound lengths and arrays, normalise strings and avoid coercion traps such as the string false becoming true. Failures return one field-precise 422 format.

  • Backend & APIs
  • $input-validation
  • 196 lines
  • 2 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- input-validation

Installs to ~/.agents/skills/input-validation/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $input-validation, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Adds schema validation at every trust boundary - HTTP bodies, query strings, path params, headers, env vars, queue messages, webhook payloads and third-party API responses - using Zod 4, Valibot or Pydantic v2, with parse-don't-validate typed outputs, unknown-key policy, safe coercion, normalisation, size limits, mass-assignment protection and consistent 422 errors. Use when an endpoint trusts req.body, a crash comes from unexpected input shape, adding env var validation, sharing schemas between client and server, or reviewing an API for injection and over-posting.

Use it when

  • Handlers read req.body, query params or messages without a schema
  • Bad input causes 500s deep inside business logic
  • Env vars and config need validation at boot
  • A review found mass assignment or missing size limits

Use something else when

How to check the result

Table-driven tests reject missing, mistyped, oversized and extra fields with one 422 listing every problem, and an injected role or tenantId never reaches the database.

The mistake it prevents

Coercing query and env strings with z.coerce.boolean, which turns the text false into true and silently enables flags.

~/.agents/skills/input-validation/SKILL.md

--- name: input-validation description: Adds schema validation at every trust boundary - HTTP bodies, query strings, path params, headers, env vars, queue messages, webhook payloads and… license: MIT ---

Done well means: untrusted data is parsed into a typed value exactly once, at the edge where it enters; code past that point never sees raw input; schemas reject what they do not expect, cap sizes, and normalise; and failures produce one consistent, field-precise error.

Boundaries to cover

BoundaryValidateUnknown keys
HTTP JSON bodyFull schema per endpoint and methodStrip by default; reject for write endpoints where typos matter
Query and path paramsCoerce from strings, bound numbers, allowlist enums and sort fieldsReject
Headers you readFormat and length (Idempotency-Key, If-Match)n/a
Env vars and configAt process start; crash early with a clear messageIgnore
Queue messages and eventsVersioned schema; dead-letter on failure, never crash-loopAllow (forward compatibility)
WebhooksAfter signature verification (webhook-receivers)Allow
Third-party API responsesThe fields you use, so upstream changes fail loudly at the edgeAllow
Files and CSV rowsPer-row schema, collect errors with row numbersReport

Validation checks shape and local rules. It does not replace authorization (“may this user edit this order?”), uniqueness (enforce with a DB constraint), or output encoding (XSS, SQL parameters). Use sql-injection-prevention and xss-prevention for those.

Pick the library

StackDefaultNotes
TypeScript server or full-stackZod 4Largest ecosystem; implements Standard Schema
TypeScript where bundle size matters (edge, client forms)ValibotModular, tree-shakes to a few KB; Standard Schema too
Python (FastAPI, scripts, workers)Pydantic v2Rust core, fast; FastAPI uses it natively
Existing framework validation (DRF serializers, Rails strong params, Laravel FormRequest, Bean Validation)Keep itDo not add a second validation layer for the same input

Read AGENTS.md and package.json / pyproject.toml first; reuse the schema library already in the project. Adding one needs network to install; ask in a sandbox without it.

Schema rules

  • Explicit allowlist. Every accepted field is named. Never spread req.body into an ORM call; that is mass assignment (role, tenantId, isAdmin arrive uninvited).
  • Separate input and output schemas. CreateUserInput is not the User model; IDs, timestamps and server-owned fields are not in input schemas.
  • Bound everything. Max string length (names 100-200, free text 5-10k), array length, numeric ranges, nesting depth. Also cap request body size at the server (e.g. 1 MB).
  • Normalise. Trim strings, lowercase emails, Unicode-normalise (NFC) identifiers, parse dates to UTC instants. Validate after normalising.
  • Enums over free strings for status, sort fields, and anything used to build queries.
  • Money as strings or integer minor units, never floats.

Zod 4

import { z } from "zod";

export const CreateOrder = z.strictObject({
  customerId: z.uuid(),
  email: z.string().trim().toLowerCase().max(254).pipe(z.email()), // normalise, then check
  note: z.string().trim().max(2000).optional(),
  items: z.array(z.strictObject({
    sku: z.string().regex(/^[A-Z0-9-]{3,32}$/),
    quantity: z.int().min(1).max(999),
  })).min(1).max(100),
});
export type CreateOrder = z.infer<typeof CreateOrder>;

export const ListOrdersQuery = z.object({
  page: z.coerce.number().int().min(1).max(10_000).default(1),
  sort: z.enum(["created_at", "-created_at", "total", "-total"]).default("-created_at"),
  includeArchived: z.stringbool().default(false),   // "true"/"false"/"1"/"0" -> boolean
});

app.post("/orders", async (req, res) => {
  const parsed = CreateOrder.safeParse(req.body);
  if (!parsed.success) return sendValidationProblem(res, parsed.error.issues); // see below
  const order = await orders.create(req.user, parsed.data);                  // typed, clean
  res.status(201).json(order);
});

// Env: fail at boot, not at the first request that needs the value
export const env = z.object({
  DATABASE_URL: z.url(),
  PORT: z.coerce.number().int().default(3000),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
}).parse(process.env);

Zod 4 moved string formats to top-level functions (z.email(), z.uuid(), z.url()); the z.string().email() forms are deprecated. Use z.prettifyError(err) for logs and err.issues (each has path and message) for API responses.

Valibot

import * as v from "valibot";

export const Signup = v.strictObject({
  email: v.pipe(v.string(), v.trim(), v.toLowerCase(), v.email(), v.maxLength(254)),
  password: v.pipe(v.string(), v.minLength(12), v.maxLength(128)),
});
const result = v.safeParse(Signup, body);
if (!result.success) return sendValidationProblem(res, result.issues);
const input: v.InferOutput<typeof Signup> = result.output;

Pydantic v2

from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator

class OrderItem(BaseModel):
    model_config = ConfigDict(extra="forbid")
    sku: Annotated[str, Field(pattern=r"^[A-Z0-9-]{3,32}$")]
    quantity: Annotated[int, Field(ge=1, le=999, strict=True)]

class CreateOrder(BaseModel):
    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
    customer_id: UUID
    email: EmailStr                                   # needs the email-validator package
    note: Annotated[str, Field(max_length=2000)] | None = None
    items: Annotated[list[OrderItem], Field(min_length=1, max_length=100)]

    @field_validator("email")
    @classmethod
    def lower(cls, v: str) -> str:
        return v.lower()

order = CreateOrder.model_validate_json(raw_body)    # faster than json.loads + model_validate

For env vars use pydantic-settings (BaseSettings). Pydantic’s default lax mode coerces "5" to 5; that is right for query strings but set strict=True on body fields where a string should be an error.

Error output

Return 422 (400 for unparseable JSON) with every issue and its location, in the project’s error format (problem-details-errors for RFC 9457):

function sendValidationProblem(res, issues: { path: PropertyKey[]; message: string }[]) {
  res.status(422).type("application/problem+json").json({
    type: "https://api.example.com/problems/validation-error",
    title: "Request validation failed", status: 422,
    errors: issues.map((i) => ({ pointer: "#/" + i.path.map(String).join("/"), detail: i.message })),
  });
}

Valibot issues carry path as objects (issue.path?.map(p => p.key)); adapt the mapper. Never echo the full rejected value back for secrets (passwords, tokens).

Share schemas without leaking

In monorepos, put input schemas in a shared package so the client form (react-hook-form-zod) and server use the same rules. The server must still validate: client validation is UX, not security. Keep server-only refinements (DB lookups) out of the shared package.

Workflow checklist

  • List every boundary in the touched code (routes, consumers, env, external calls)
  • Reuse the project’s schema library; write input schemas separate from models
  • Bound sizes, allowlist enums and sort fields, normalise strings
  • Parse once at the edge; pass typed data inward; remove ad-hoc checks deeper down
  • Map failures to one 422 format; log with prettifyError or errors()
  • Add tests for invalid inputs (below); run the project’s test command

Verification

Table-driven tests per schema: valid minimal, valid maximal, each field missing, each field wrong type, over-length strings, oversized arrays, extra keys (role: "admin" must be rejected or stripped and never persisted), and boundary numbers. Then hit the real endpoint:

curl -s -X POST localhost:3000/orders -H 'content-type: application/json' \
  -d '{"customerId":"x","items":[],"role":"admin"}' | jq

Expect 422 with one entry per problem and no database write.

Gotchas

  • z.coerce.boolean() turns the string "false" into true; use z.stringbool() for env and query flags.
  • z.coerce.number() turns "" into 0; add .min() or preprocess empty strings to undefined.
  • Validating req.body but reading req.query or req.params raw elsewhere in the handler.
  • Regexes on unbounded input can be catastrophic (ReDoS); bound length before matching and avoid nested quantifiers.
  • z.object strips unknown keys silently; clients sending emial get no error. Use strict objects on write endpoints.
  • Validating then using the original object instead of the parsed output loses trimming and coercion.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Input Validation"
  short_description: "Parse untrusted data once, at the edge"
  default_prompt: "$input-validation Add Zod validation to every route in this Express API"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem