Handling Invalid Sort Field Errors
This page is part of the Sorting & Multi-Field Ordering reference under Query Patterns & Data Shaping Strategies. It shows how to turn a vague 500 or a silent fallback into a precise, machine-readable error when a client asks to sort by a field your endpoint does not allow — using an allowlist, the right HTTP status, and an RFC 9457 Problem+JSON body that tells the client exactly what to fix.
The Symptom
A sort parameter such as ?sort=-created_at,name looks harmless, but the field names come straight from an untrusted client. When one of them is not a real, sortable column, endpoints tend to fail in one of four ways: a raw database error leaks as a 500, the parameter is silently ignored (so the client sees unsorted data with a 200), the value is interpolated into SQL and becomes an injection vector, or the query runs an unbounded sort on a non-indexed column and times out. Each of these is a contract failure. The fix is to validate every requested field against an allowlist and reject anything outside it with a deterministic status code and body.
The companion guide on building efficient multi-column sort endpoints covers the happy path — composite indexes and stable tiebreakers. This page covers what happens when validation fails.
Decision Table: Classifying a Bad Sort Request
Map each failure mode to a status code before writing any handler code. The direction column (asc/desc, or +/- prefix) is part of the token, so a bad direction is a syntax error, not a semantic one.
| Symptom | Example input | Classification | Status |
|---|---|---|---|
| Unknown field name | ?sort=nickname |
Well-formed, not on allowlist | 422 |
| Bad sort direction | ?sort=name:sideways |
Malformed token | 400 |
| Non-indexed / non-sortable real column | ?sort=bio |
Well-formed, deliberately excluded | 422 |
| SQL-identifier injection attempt | ?sort=id;DROP TABLE u-- |
Malformed token | 400 |
| Empty or duplicate field | ?sort=,name or ?sort=id,id |
Malformed token | 400 |
The dividing line is simple: if you cannot even parse the token into a clean (field, direction) pair, it is 400; if it parses but the field is not one you permit sorting on, it is 422. Both a non-sortable real column (bio) and a fictional field (nickname) return the same 422 so an attacker cannot tell which names are real columns.
The OpenAPI Contract
Declare the sortable vocabulary in the spec so the error is documented and codegen can surface allowed values. A regex-constrained string keeps the parameter self-describing:
# openapi/components/parameters/Sort.yaml (OpenAPI 3.1.0)
Sort:
name: sort
in: query
required: false
description: >
Comma-separated list of fields. Prefix with '-' for descending.
Allowed fields: id, created_at, updated_at, name.
schema:
type: string
pattern: '^-?(id|created_at|updated_at|name)(,-?(id|created_at|updated_at|name))*$'
example: "-created_at,name"
The pattern is a first line of defense at the edge (a gateway can reject on it), but never rely on it alone — validate again in the handler against the same allowlist so the rule has a single source of truth.
Validation Flow
Step-by-Step Resolution
Step 1 — Parse before you validate
Split the parameter into tuples and reject anything that does not match the token grammar. This is where 400 cases are caught.
// TypeScript — parse and normalize sort tokens
type SortTerm = { field: string; dir: "asc" | "desc" };
const TOKEN = /^(-)?([a-z_]+)$/;
export function parseSort(raw: string): SortTerm[] {
const seen = new Set<string>();
return raw.split(",").map((token) => {
const m = TOKEN.exec(token.trim());
if (!m) throw new SortSyntaxError(token); // -> 400
const field = m[2];
if (seen.has(field)) throw new SortSyntaxError(token); // duplicate -> 400
seen.add(field);
return { field, dir: m[1] ? "desc" : "asc" };
});
}
Step 2 — Validate against the allowlist
Map each public field to a fixed internal column. The requested string is never concatenated into SQL — only the mapped, hard-coded column is.
// TypeScript — allowlist maps public name -> trusted column
const SORTABLE: Record<string, string> = {
id: "id",
created_at: "created_at",
updated_at: "updated_at",
name: "display_name",
};
export function toOrderBy(terms: SortTerm[]): string {
const invalid = terms.filter((t) => !(t.field in SORTABLE));
if (invalid.length) {
throw new SortFieldError(invalid.map((t) => t.field)); // -> 422
}
return terms
.map((t) => `${SORTABLE[t.field]} ${t.dir === "desc" ? "DESC" : "ASC"}`)
.join(", ");
}
# Python — allowlist-based validation
SORTABLE = {
"id": "id",
"created_at": "created_at",
"updated_at": "updated_at",
"name": "display_name",
}
def to_order_by(terms: list[tuple[str, str]]) -> str:
invalid = [f for f, _ in terms if f not in SORTABLE]
if invalid:
raise SortFieldError(invalid) # -> 422
return ", ".join(
f"{SORTABLE[f]} {'DESC' if d == 'desc' else 'ASC'}" for f, d in terms
)
Step 3 — Emit the Problem+JSON body
Return application/problem+json with a detail a human can act on and an errors array a machine can parse. List the allowed values so the client can self-correct.
# Python (FastAPI) — 422 Problem+JSON for a non-sortable field
from fastapi import Request
from fastapi.responses import JSONResponse
async def sort_field_handler(request: Request, exc: "SortFieldError"):
return JSONResponse(
status_code=422,
media_type="application/problem+json",
content={
"type": "https://api-contract.com/problems/invalid-sort-field",
"title": "Invalid sort field",
"status": 422,
"detail": (
f"Cannot sort by {', '.join(exc.fields)}. "
"Allowed fields: id, created_at, updated_at, name."
),
"instance": str(request.url),
"errors": [
{
"field": f,
"reason": "not_sortable",
"allowed": ["id", "created_at", "updated_at", "name"],
}
for f in exc.fields
],
},
)
{
"type": "https://api-contract.com/problems/invalid-sort-field",
"title": "Invalid sort field",
"status": 422,
"detail": "Cannot sort by nickname. Allowed fields: id, created_at, updated_at, name.",
"instance": "/v1/users?sort=nickname",
"errors": [
{ "field": "nickname", "reason": "not_sortable", "allowed": ["id", "created_at", "updated_at", "name"] }
]
}
For a malformed token the shape is identical but the type points at .../problems/malformed-sort and the status is 400. Keeping one Problem+JSON structure across both means clients parse a single error contract.
RFC and Standard Alignment
| Concern | Standard | Rule |
|---|---|---|
| Malformed sort token | RFC 9110 §15.5.1 | 400 Bad Request — the server cannot parse the parameter |
| Well-formed but not sortable | RFC 9110 §15.5.21 | 422 Unprocessable Content — understood, but semantically rejected |
| Error body format | RFC 9457 | application/problem+json with type, title, status, detail |
| Extension members | RFC 9457 §3.2 | errors array is a permitted extension member |
| Schema enumeration defense | OWASP API3:2023 | Return identical 422 for real-but-excluded and fictional fields |
RFC 9457 obsoletes RFC 7807 but keeps the media type and member names, so existing Problem+JSON tooling continues to work unchanged.
Caching and Safety Implications
A validation failure is a client error and must never be cached as if it were data. Send Cache-Control: no-store on 400 and 422 responses so a shared cache does not serve a stale error after the client fixes its request. The successful sorted GET remains safe and idempotent; only the query string changes the result, so vary any cache key on the full sort parameter. Critically, an invalid sort must fail closed — never fall back to an arbitrary default sort and return 200, because a client that silently receives unsorted data will paginate incorrectly and may skip or duplicate records across pages.
SDK and Codegen Downstream Effect
When the sort parameter is a constrained enum-backed pattern in the spec, generators can emit a typed union instead of a bare string, moving the invalid-field error from runtime to compile time:
// Generated client — allowed sort fields become a union type
type SortField = "id" | "created_at" | "updated_at" | "name";
type SortTerm = SortField | `-${SortField}`;
function listUsers(params: { sort?: SortTerm[] }): Promise<UserPage>;
// listUsers({ sort: ["-created_at", "name"] }); // ok
// listUsers({ sort: ["nickname"] }); // compile error
Generators that treat sort as an unconstrained string cannot catch nickname until the server returns 422. Publishing the allowlist in the OpenAPI pattern is what lets the SDK reject bad fields before a request is ever sent.
Common Mistakes
| Mistake | Correct approach |
|---|---|
Interpolating the raw field name into ORDER BY |
Map each public field to a fixed column via an allowlist |
Returning 500 when the DB rejects an unknown column |
Validate first; a bad field is a 400/422, never a 500 |
Silently ignoring a bad field and returning 200 unsorted |
Fail closed with a 422 and a Problem+JSON body |
| Different responses for real vs fictional fields | Return identical 422 to block schema enumeration |
| Caching the error response | Send Cache-Control: no-store on 4xx |
FAQ
Should an invalid sort field return 400 or 422?
Use 400 when the sort token itself is malformed — an empty field, an unknown direction like name:sideways, stray characters, or a duplicate field — because the request cannot be parsed. Use 422 when the request parses cleanly but names a field that is not on the sortable allowlist. This split lets a client distinguish a serialization bug in its own code (400) from an unsupported-but-understood request (422), and it matches RFC 9110’s semantics for the two status codes.
Is it safe to echo the invalid field name back in the error?
Yes, echo back the exact value the client sent so the detail and errors array are actionable. What you must not do is confirm whether an unknown field corresponds to a real database column. Return the same 422 for a non-sortable real column (like password_hash or bio) as for a completely fictional field (like nickname). If real columns produced a different error than fictional ones, the endpoint would become an oracle an attacker could use to enumerate your schema.
Why validate against an allowlist instead of escaping the sort field?
SQL identifiers — column names and sort directions — cannot be sent as bound parameters the way values can, so the only alternatives are escaping or an allowlist. Escaping identifiers is fragile and easy to get subtly wrong across database drivers. A static allowlist that maps each public field name to a fixed internal column removes the raw client string from the query entirely, which is the only approach that fully prevents SQL-identifier injection while also blocking sorts on expensive non-indexed columns.
Related
- Sorting & Multi-Field Ordering — up-link: the section covering sort contract design and multi-field ordering
- Query Patterns & Data Shaping Strategies — up-link: the parent reference on filtering, sorting, pagination, and projection
- Building Efficient Multi-Column Sort Endpoints — the happy-path sibling covering composite indexes and stable tiebreakers
- Debugging Projection Field Validation Errors — the same allowlist pattern applied to sparse-fieldset field validation
- RSQL and FIQL Filter Operator Syntax — validating operator and field grammar in filter expressions