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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- input-validation Installs to ~/.agents/skills/input-validation/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install input-validation The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project input-validation Installs to .agents/skills/input-validation/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/input-validation.zipunzip -o input-validation.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) input-validation PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- The work is client-side form state and UX: use react-hook-form-zod
- You need Pydantic modelling beyond request validation: use pydantic-v2
- The concern is untrusted file uploads: use file-upload-security
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.
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
| Boundary | Validate | Unknown keys |
|---|---|---|
| HTTP JSON body | Full schema per endpoint and method | Strip by default; reject for write endpoints where typos matter |
| Query and path params | Coerce from strings, bound numbers, allowlist enums and sort fields | Reject |
| Headers you read | Format and length (Idempotency-Key, If-Match) | n/a |
| Env vars and config | At process start; crash early with a clear message | Ignore |
| Queue messages and events | Versioned schema; dead-letter on failure, never crash-loop | Allow (forward compatibility) |
| Webhooks | After signature verification (webhook-receivers) | Allow |
| Third-party API responses | The fields you use, so upstream changes fail loudly at the edge | Allow |
| Files and CSV rows | Per-row schema, collect errors with row numbers | Report |
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
| Stack | Default | Notes |
|---|---|---|
| TypeScript server or full-stack | Zod 4 | Largest ecosystem; implements Standard Schema |
| TypeScript where bundle size matters (edge, client forms) | Valibot | Modular, tree-shakes to a few KB; Standard Schema too |
| Python (FastAPI, scripts, workers) | Pydantic v2 | Rust core, fast; FastAPI uses it natively |
| Existing framework validation (DRF serializers, Rails strong params, Laravel FormRequest, Bean Validation) | Keep it | Do 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.bodyinto an ORM call; that is mass assignment (role,tenantId,isAdminarrive uninvited). - Separate input and output schemas.
CreateUserInputis not theUsermodel; 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
prettifyErrororerrors() - 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"intotrue; usez.stringbool()for env and query flags.z.coerce.number()turns""into0; add.min()or preprocess empty strings to undefined.- Validating
req.bodybut readingreq.queryorreq.paramsraw elsewhere in the handler. - Regexes on unbounded input can be catastrophic (ReDoS); bound length before matching and avoid nested quantifiers.
z.objectstrips unknown keys silently; clients sendingemialget 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.yaml
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.