AI Models

Guardrails: Validating Input Before It Reaches the Model

How to validate and sanitize user input for LLMs using schema checks, content filters, and rate limits to cut costs and improve safety.

Mohammed Saqib9 min read
An IT professional operates a computer in a server room, managing network systems and connected devices.
Photo by panumas nikhomkhai on Pexels · Pexels License

Every token you send to an LLM is billed, and every token you don't need to send is wasted latency. A malformed payload, a prompt injection attempt, or a runaway client still triggers a model call unless you catch it at the API boundary. Input guardrails—schema validation, content filters, and rate limits—are the difference between paying for garbage and rejecting it before it costs you anything.

Why Validate Input Before the Model Sees It

The economics are simple: model operators bill on tokens, not on request quality. A two-thousand-word prompt that fails content policy still consumes those tokens, and the completion may be just as expensive. A request rejected at the edge costs one round trip through an HTTP framework; a request sent to the model costs a full inference cycle. Rejecting bad input before the API call is therefore neither paranoia nor compliance theater—it is a direct cost control.

Prompt injection is why this is also a security issue. A user-controlled string that tells the model to reveal its system prompt or ignore prior instructions can turn a summarisation endpoint into a data exfiltration channel. Schema validation filters out the request shapes that cannot possibly be valid; content filters catch the semantics that schema cannot express. If you are measuring spend by prompt tokens alone, Token counting is wrong: measure actual LLM cost explains why that number understates the true expense.

Validation at the edge also keeps your logs and metrics clean. Invalid requests that reach the model produce completions, which produce log entries, which pollute analytics and make incident review harder. Every rejected request should generate one structured log line and stop there. Your model endpoint then only sees traffic that is structurally valid, passes content policy, and fits within the user’s budget.

Schema Validation with JSON Schema or Pydantic

The first question is whether the request has the shape you can process. If your endpoint expects a prompt, a language, and a maximum number of sentences, reject everything else before business logic runs. This is exactly the problem JSON Schema and Pydantic solve.

In Python, Pydantic serves as both the schema definition and the validation layer. A FastAPI endpoint can declare a request model and automatically return 422 when a payload violates it:

from typing_extensions import Annotated
 
from pydantic import BaseModel, Field, StringConstraints
 
Prompt = Annotated[str, StringConstraints(min_length=1, max_length=2000, strip_whitespace=True)]
 
class SummarizeRequest(BaseModel):
    prompt: Prompt
    language: str = Field(default="en", pattern="^(en|de|fr)$")
    max_sentences: int = Field(default=3, ge=1, le=10)

Field constraints do more than type-check. min_length and max_length bound the number of tokens; the language pattern restricts the field to an enum without a separate validator; ge and le keep generation parameters inside their documented ranges. A payload with a missing prompt, a prompt that is only whitespace, or a language like "en-US" never reaches the model. The Pydantic docs describe the full set of constraints if you need regex, email, or list-length checks.

Away from Python, JSON Schema is the portable equivalent. It is language-neutral and works well with validators such as Ajv in JavaScript or gojsonschema in Go:

import Ajv from "ajv";
 
const schema = {
  type: "object",
  additionalProperties: false,
  required: ["prompt"],
  properties: {
    prompt: { type: "string", minLength: 1, maxLength: 2000 },
    mode: { enum: ["fast", "balanced", "thorough"] },
  },
};
 
const validate = new Ajv().compile(schema);
 
export function parseChatRequest(body) {
  if (!validate(body)) {
    const err = new Error("Invalid request payload");
    err.status = 422;
    err.details = validate.errors;
    throw err;
  }
  return body;
}

Notice additionalProperties: false. That single flag rejects unknown fields instead of silently passing them through to whatever prompt templating code you have. It is a cheap way to ensure the request that reaches the model is exactly the request your code understands. Structured input makes it trivial to catch malformed data that would otherwise produce garbage completions. If you are also making the model produce structured output rather than only consume it, the decisions overlap with Function calling vs structured outputs: how to decide.

Content Filters for Harmful or Irrelevant Input

Schema validation only constrains structure, not meaning. A free-text field can be perfectly well-formed and still contain an injection attempt, personal data, or content you do not want to send to a model. Content filters sit after schema validation and before the model call.

For obvious attack patterns, a blocklist is still the right tool. It is deterministic, cheap, and easy to reason about. The following function normalizes the input and checks a few injection patterns before anything is allowed through:

import re
import unicodedata
from typing import Pattern
 
INJECTION_PATTERNS: list[Pattern[str]] = [
    re.compile(r"ignore (all )?(previous|prior) instructions", re.IGNORECASE),
    re.compile(r"forget (everything|your instructions)", re.IGNORECASE),
    re.compile(r"\bsystem prompt\b", re.IGNORECASE),
]
 
def blocklisted(text: str) -> str | None:
    normalized = unicodedata.normalize("NFC", text)
    for pattern in INJECTION_PATTERNS:
        if pattern.search(normalized):
            return pattern.pattern
    return None

If blocklisted returns a match, return a 400 or 422 response from your API layer and do not call the model. There is no reason to give a request that says ignore all previous instructions a chance to interact with your system prompt.

Blocklists are not enough by themselves. Attackers paraphrase, split words, or use visually confusable Unicode characters. To catch novel patterns, add a small classifier—a fine-tuned model, or a purpose-built moderation API—that scores input for toxicity, PII, or off-topic content. Set a threshold that balances false positives against safety, and log every rejection so you can tune the threshold on real traffic. In production we usually run the classifier only when a rule-based check has not already rejected the input. This keeps the common path fast while still catching the uncommon and adversarial cases.

Rate Limiting and Budget Controls

Content filters protect the meaning of the input; rate limits protect your capacity and your wallet. A single user can accidentally, or deliberately, burn through an API quota by sending one request per second all afternoon. Per-user limits stop that before the model bills you for it.

Request counts are only part of the story. Two users may send the same number of requests, but one of them uses long documents and consumes fifty times the tokens. Token-based budget tracking is the more honest control: cap total tokens per user per hour, not just request count. The exact estimator matters less than consistency. A simple sliding window token budget looks like this in Python:

import time
from collections import deque
 
class TokenBudget:
    """Single-process sliding window token budget. Use Redis in multi-node deployments."""
 
    def __init__(self, window_seconds: float, max_tokens: int):
        self.window_seconds = window_seconds
        self.max_tokens = max_tokens
        self._events: deque[tuple[float, int]] = deque()
 
    def try_consume(self, tokens: int) -> bool:
        now = time.monotonic()
        while self._events and self._events[0][0] <= now - self.window_seconds:
            self._events.popleft()
 
        if sum(used for _, used in self._events) + tokens > self.max_tokens:
            return False
 
        self._events.append((now, tokens))
        return True

When try_consume returns False, respond with HTTP 429 and a Retry-After header so clients know when they can retry. The code above is intentionally single-process; a multi-instance deployment needs Redis or another shared store so budgets hold across replicas. Sliding window counters are more accurate than fixed windows and still cheap to implement.

Comparing Approaches: Blocklists vs ML Classifiers

There is no single guardrail that catches everything. It helps to compare the main filtering strategies on dimensions that matter operationally.

Check Deterministic? Bypass resistance Latency cost Maintenance
Blocklist Yes Low against paraphrasing and Unicode tricks Negligible Pattern updates
ML classifier No Higher against novel attacks Measurable per request Periodic retraining
Hybrid blocklist-first Prefix deterministic, classifier for what remains High when the classifier is tuned Low for blocklist hits, higher otherwise Both of the above

Blocklists are fast and easy to audit, but they fail the moment an attacker rewrites the phrase. ML classifiers catch patterns the blocklist has never seen, but they add latency and need regular retraining to stay current. Schema validation sits alongside both: it is deterministic and cheap, but it cannot judge meaning.

For most endpoints the hybrid is the right default. Run the blocklist first because it costs almost nothing, then run a classifier only on input that passes it. Input that is clearly malicious gets rejected in the first step; ambiguous or inventive attempts get a deeper semantic check. The table is also a reminder that every filter has false positives. A blocklist pattern like system prompt will match innocent conversations where someone is literally asking about system prompts. That trade-off has to be tuned per endpoint.

Failure Modes and Gotchas

Guardrails themselves cause failures if you do not test them against real traffic. Overly aggressive filters reject legitimate users, and the error message gives them no path forward. Run a shadow mode for a few days, log what each filter would have rejected, and compare against actual requests before you enforce a new blocklist or classifier threshold.

Unicode handling is a classic source of false negatives. The string Hello uses a full-width e, so a naive blocklist searching for hello misses it. Unicode normalization is the first step: the Unicode Normalization report defines NFC as the canonical form, and most libraries will collapse full-width variants into their ASCII equivalents. Normalize input to NFC at the edge, then validate, then run filters. Homoglyph attacks that still slip through NFC need a transliteration or visual-confusion table on top.

Finally, understand what schema validation is not. A perfectly valid JSON payload can still ask the model to ignore previous instructions in a free-text field. That is why validation is a stack: structure, content, rate, then the model call itself. No single layer makes the others unnecessary.

Logging and Monitoring Guardrails

Rejections are data. They tell you what attackers are trying, which filters are too strict, and whether a new model prompt has changed what users type. Log every rejection with a reason, a user ID, and a hash of the input so you can investigate without storing raw user content. The input hash lets you trace repeat offenders without keeping a database of PII.

Keep the logs structured and greppable. Each rejection line should name the guardrail, the decision, and the time spent in that guardrail. A useful line looks like guardrail=blocklist decision=reject reason=injection_pattern user_id=abc123 latency_ms=0.4. When you split logs by guardrail name, a spike in one filter type is immediately visible.

Track two numbers per filter: hit rate and false positive ratio. A rising blocklist hit rate can mean an active attack or a new prompt template that tripped a pattern. A rising false positive ratio means your filter is starting to reject legitimate users, and you should adjust before support starts noticing. Alert on both.

Key takeaways

  • Validate request structure with schema and constraint checks before any prompt is assembled.
  • Blocklists stop obvious injections at negligible cost; classifiers catch the paraphrases blocklists miss.
  • Rate limit on tokens per user, not just requests, and return 429 with a Retry-After header.
  • Normalize Unicode before filtering, and test every new guardrail in shadow mode before enforcing it.
  • Log every rejection with reason, user hash, and per-guardrail latency; monitor hit rates and false positives.

Frequently asked questions

Can guardrails prevent all prompt injections?
No single guardrail catches every attack. Schema validation stops structural exploits, content filters catch known patterns, but adversarial prompts using homoglyphs or context manipulation can still slip through. Defense in depth with monitoring is essential.
How much latency do input guardrails add?
Schema validation adds under 5ms. Blocklist checks are similarly fast. ML classifiers add 50-200ms depending on model size and hardware. For most use cases, the cost savings from rejecting bad requests early outweigh this latency.
Should I validate input on the client side too?
Client-side validation improves UX by catching obvious errors before a network round trip. But never rely on it for security—always revalidate on the server, since client checks can be bypassed trivially.
#llm#security#validation#guardrails#safety
Share

Keep reading