Pattern · last reviewed 2026-07-25

Guardrails defense-in-depth: the layers one filter can't cover

The short answer

Defense-in-depth means the guardrail is a stack of independent layers, not a single filter. A production LLM system — and any agent built on one — screens input for prompt injection, hardens the model with a system prompt, constrains what the model can do through a least-privilege tool boundary, validates output before it becomes an action, and wraps all of that in infrastructure-level controls — gateway guardrails and spend caps — with monitoring across every layer.

Reach for it whenever a failure of one control is unacceptable: an agent with real permissions, a public-facing chat surface, anything touching money, data, or irreversible actions. The premise is that no single filter catches everything, so you assume each layer will sometimes fail and make sure the next one still holds.

What stops one bad request in production — an injection, a leak, a runaway loop — from reaching a user? Not an eval score: a case-set average says the model is good on average, not that this request is safe. That per-request job belongs to layer 6 of the AI quality stack, guardrails.

Part of the AI quality stack — the layered gate chain for knowing your LLM is delivering quality.

Context and problem

You ship an LLM feature with a content filter on the input, and it feels done. Then the failures arrive from directions the filter never sees. A résumé uploaded for summarization contains hidden text instructing the agent to email its contents elsewhere — indirect prompt injection, and your input filter passed it because the malicious instruction rode in as retrieved content, not as a user message. The model, given a broadly-scoped database tool, executes a write it was manipulated into making. A verbose tool result leaks an internal identifier into a reply. A runaway loop burns a month of budget in an afternoon.

None of these is caught by "the safety filter" because they are not one class of problem. Prompt injection is an input problem; excessive agency is a permissions problem; data leakage is an output problem; cost blowout is an infrastructure problem. A single filter, however good, is a single point of failure sitting at exactly one point in a system that fails at several. Defense-in-depth is the borrowed security discipline that answers the real question a senior engineer asks: not "which filter do I buy," but "when this layer fails — and it will — what stops the incident."

Forces

  • Coverage vs. friction. Every layer you add stops a class of attack and costs latency, false positives, and code. Too few layers leave gaps; too many turn a legitimate request into a rejection.
  • Safety vs. legitimate use. A guardrail tuned to block anything that looks like an attack will block the security engineer discussing attacks. Context decides whether a match should block or merely flag.
  • Autonomy vs. blast radius. The more an agent can do without asking, the more a single manipulation can do through it. Least privilege buys safety at the cost of capability.
  • Prevention vs. observability. Blocking stops the current request but tells you little; flagging lets it through but shows you what your users and attackers actually do. Mature systems need both, on different layers.

The pattern

Arrange independent controls so a request passes through several before it can cause an effect, and a response passes through several before it reaches anyone. Each layer defends a different surface; none of them is trusted to be sufficient alone.

  • Input layer. Screen prompts before they reach the model — prompt-injection and jailbreak detection, PII and sensitive-topic filtering. Treat retrieved content (documents, tool results, web pages) as untrusted input too, because indirect injection lives there, not just in the user's typed message.
  • Model layer. Harden the system prompt: state the model's role and boundaries, and instruct it to treat tool output and retrieved text as data, never as commands. This is the cheapest layer and the weakest on its own — a request, not an enforcement — so it never stands alone.
  • Tool boundary. The highest-leverage layer for agents. Give tools the narrowest permissions that let them work (read-only where possible, no access to billing or account mutation), allowlist what can be called, and gate irreversible actions behind a human. This is the direct countermeasure to OWASP's LLM06, Excessive Agency: a manipulated model can only do what its tools permit.
  • Output layer. Validate what comes back before it becomes an action or a rendered reply — schema validation on structured output, grounding checks against sources, PII scrubbing, and re-screening for content the model may have been coaxed into producing.
  • Infrastructure layer. Controls that live below the application: gateway-level guardrails proxying every call, hard spend caps that fail closed, and a WAF in front of the public endpoint. These catch what the app-level layers miss and bound the damage of anything that gets through.
  • Monitoring layer. Not a barrier but a lens across all the others: log flagged interactions, tool calls, and cost per request. Monitoring is where flag-mode guardrails earn their keep — they let the request through and show you the attack, which is how you tune the blocking layers without breaking legitimate traffic.
Layered guardrails around an LLM agent Untrusted input passes through an input-screening layer into a model hardened by its system prompt; the model's tool calls cross a least-privilege tool boundary; the model's output is validated before it becomes an action or response. An infrastructure layer of gateway guardrails and spend caps wraps the whole flow, and a monitoring layer observes every stage. Infrastructure · AI Gateway guardrails · spend caps · WAF Input screening injection · PII Model system-prompt hardening Output validation schema · grounding · PII Tool boundary allowlist · least privilege action / response untrusted input Monitoring & logging — flag mode observes every layer

The single most important property of the diagram is that the arrows do not skip layers. Input passes screening before the model; the model reaches effects only through the tool boundary; nothing leaves without output validation; the gateway and monitoring see all of it. Remove any one box and a whole class of failure walks straight through.

Flag, ignore, or block — and why context decides

Every layer that can match content has three settings, not the two people default to: block the request, flag it and let it pass, or ignore the category entirely — turn a hazard check off for something that structurally cannot apply to this surface. The instinct is to block everything — it feels safer. It is not, universally. A guardrail that blocks any prompt resembling an attack cannot tell an attacker probing for injection from a student learning about injection, or a security engineer writing a detection rule. Block mode on that surface produces false positives that break the legitimate case, and worse, it hides the traffic you most need to see.

The rule is: block where a match is unambiguously an abuse and the cost of a false positive is low (a public chatbot fielding obvious jailbreaks); flag where the same content is legitimate in one context and hostile in another, where you are still learning your traffic, or where a false positive breaks a real user; ignore a category that is structurally irrelevant to the surface, so it never costs you a false positive at all. Flag mode keeps the request flowing while logging it, which turns the monitoring layer into a tuning instrument — you watch what actually gets flagged before you decide what to block. This is not a default you set once; it is a per-surface, per-layer, per-category decision, and it is why a mature guardrail stack runs some categories blocking, some flagging, and some switched off, all at once.

A race is not a security boundary

Every layer costs latency, so the tempting optimization is to run a check alongside the thing it guards instead of in front of it. Concretely, for an input classifier on a streaming model call: fire the classifier and the model call at the same time, stream the reply immediately, and if the classifier returns a bad verdict, redact what has already gone out. It reads like defense-in-depth with the latency removed. It is not a control at all.

It fails for three independent reasons, and each one kills it alone. Redaction after the fact only repairs a compliant client. Anything already on the wire has been delivered; a client that ignores the redaction instruction keeps exactly what it received, and a hostile client is the case the layer exists for. Tool dispatch does not wait for the verdict. By the time a verdict arrives the model may already have called tools with side effects, and no late verdict un-calls them. And the stream had six distinct terminal paths, each of which would need its own gate — six chances to miss one, on every future edit, forever. Any one of those three is enough; together they say the design was never a boundary, only a slower alarm.

The sequential version — classify, then call — was strictly simpler and the only one that actually holds. Generalize the rule: a check that runs alongside the effect it is meant to prevent is monitoring, not enforcement. Enforcement has to complete before the effect begins. The cost is real and worth stating plainly: sequential adds the classifier's latency to every turn, including the overwhelming majority that are entirely ordinary. That was accepted because the latency is bounded by the gate's own short wait budget and its fail-open path, while the concurrent design's saving came entirely from not being a boundary. Paying for a control you have is a trade; not paying for one you do not have is not.

Reference implementation notes

The pattern is platform-independent, but each vendor gives you a different subset of the layers as managed features. Use the managed pieces for the infrastructure and content-filter layers; keep the tool boundary and output validation in your own code, where the domain logic lives.

Anthropic

At the model layer, Claude's guidance is system-prompt hardening: state the assistant's role and refusal boundaries explicitly, and instruct the model to treat tool results and retrieved documents as untrusted data rather than instructions — the core mitigation against indirect prompt injection. This is a request the model honours well but not perfectly, so it is one layer, never the whole defense. Pair it with input screening and a tool boundary; do not rely on the prompt alone to stop an agent from misusing a tool it has been granted.

AWS

Amazon Bedrock Guardrails is a managed input/output layer: configurable content filters across harm categories, denied-topic definitions, word filters, sensitive-information (PII) filters that block or anonymize, and prompt-attack detection, applied to the prompt, the response, or both. The ApplyGuardrail API lets you run the same policy independently of a model call — useful for screening retrieved content before it enters the context. Contextual grounding checks score how well a response is supported by its sources, which is an output-layer defense against unsupported claims. Bedrock gives you the input and output filter layers as a service; you still own the tool boundary (IAM least privilege on whatever the agent can call) and the surrounding orchestration.

Cloudflare

Cloudflare supplies the infrastructure layer. AI Gateway Guardrails proxy every call between your application and the model provider, evaluating prompts and responses against hazard categories with Llama Guard 3 8B on Workers AI (roughly 500ms added per request) — a content-safety classifier, not a check on your app's own logic, which is exactly why it sits alongside the tool boundary and output validation rather than replacing them. Ignore, flag, or block is set independently for prompts and for responses, and every request is logged regardless of verdict, including ones that pass clean — the reason flag mode is a genuine tuning instrument and not a no-op. Scope limit worth knowing before you lean on it for more than this: Guardrails does not evaluate streaming responses, per Cloudflare's own docs. If your model call streams its completion, as most chat surfaces do, the response-side check silently does not cover that streamed output — a gap that shows up after an incident, not before one, unless you already know to look for it. The failure mode deserves the same honesty: if the underlying evaluation call itself fails, Guardrails fails open in flag mode and fails closed in block mode — a hiccup degrades flag mode to "not evaluated this time," it does not hang the request. Cloudflare's WAF-layer offering — renamed AI Security for Apps — can inspect prompts inline at the edge, but check your plan before you design around it: the AI detection fields (prompt-injection scoring, PII, unsafe topics) are an Enterprise paid add-on, and lower tiers get LLM endpoint discovery only. We are on one of those tiers, which is why our own injection defence lives in application code rather than at the edge. The gateway is also where you enforce spend caps and rate limits — the cost-blowout layer that no application-level filter provides.

Trade-offs

  • Latency and cost. Each screening layer is an extra call or model pass. Input plus output guardrails on every request roughly doubles the guardrail overhead; grounding checks add another. Budget for it, and skip layers on low-risk internal surfaces.
  • False positives compound. Six layers each with a small false-positive rate reject more legitimate traffic than any one of them. This is the direct cost of coverage, and the reason flag mode exists — you cannot tune what you have already blocked.
  • Operational surface. More layers mean more configuration, more monitoring, more things that drift out of tune. Defense-in-depth is a system to maintain, not a checkbox.
  • False confidence. The layers can lull you into trusting the model with more autonomy than you should, on the theory that "the guardrails will catch it." Most of them are probabilistic; the tool boundary, deterministic output checks, and an audit trail of every guardrail decision are what to lean on for anything irreversible.

When not to use this

Do not build the full stack around a low-stakes, low-privilege surface. An internal tool that summarizes text with no ability to act, reachable only by trusted employees, does not need six layers — input screening it cannot use maliciously and an output check is plenty; the tool boundary is trivially satisfied because there are no tools. Layering guardrails onto it adds latency and false positives to buy safety against a threat that does not exist.

Nor should you reach for this instead of the one control that actually fits the risk. If the real exposure is a single irreversible action, the answer is a human-in-the-loop approval gate on that action, not a content filter — a filter cannot judge whether this refund is legitimate. And a fully deterministic pipeline with no model in the loop needs ordinary application security, not LLM guardrails; do not import this pattern where there is no probabilistic component to defend.

As-built evidence

aiArch runs this pattern in production, and the most useful thing about our deployment is where it forced us to relax a guardrail. This platform routes every coach call through an authenticated Cloudflare AI Gateway, with Guardrails enabled — the infrastructure layer of the diagram. We relaxed exactly one hazard category — Prompt Injection/Jailbreaks — to flag; the other thirteen stay on block, prompt side and response side both. The reason is the pattern's whole point: under block, that one category flagged our own security curriculum against us. A lesson question about prompt injection — legitimate teaching content — tripped the gateway's prompt-attack detection and returned a 424, blocking the learner from a lesson about the exact attack. Under flag the same content passes and is logged. The naïve instinct (block everything) broke a legitimate use of the system; defense-in-depth means the gateway guardrail is one tuned layer, tuned per category rather than per gateway, not a binary gate over everything. "We run it in flag mode" is the compression that sat in our own decision record for a month before someone re-read the dashboard — the setting was never gateway-wide.

That gateway guardrail has a scope limit worth stating before anyone leans on it for more than it does: it does not evaluate streaming responses. We did not take that on the vendor's word — we measured it on our own traffic. Across four live coach calls through the authenticated gateway on 2026-07-24, the first token arrived 1ms after the request went out, while the full generation took 2032ms. Nothing was buffered on the way back: tokens were already reaching the client before there was a complete response to examine, so a response-side check had nothing to inspect. Cloudflare's own documentation states the same limit, which is corroboration for a measurement rather than the basis of the claim. The paid coach streams its replies token by token, so the gateway's response-side check never actually inspects what the learner sees on that surface — it observes the prompt going in, and does not inspect the completion coming back out. That is precisely the "the control we thought we had didn't cover the surface we thought it covered" failure this pattern warns about, and it is why the load-bearing check on the coach's output is not the vendor gateway feature at all, but a first-party one, below.

The graceful-degradation half is in src/lib/llm.ts. A gateway guardrail block surfaces as a typed GuardrailBlockedError — the gateway returns error code 2016 for a blocked prompt, 2017 for a blocked response — which the coach loop catches and turns into an in-band message rather than a crashed stream. The budget-pressure path follows the identical pattern: any 429 on the gateway path becomes a BudgetExhaustedError, caught the same way — any, deliberately, because Cloudflare's docs define no header or error code distinguishing a spend-limit 429 from a rate-limit one. Which spend-limit rules are configured on our gateway is dashboard state, not repo state, so we claim no specific rule here; a monthly ceiling and a dynamic-route fallback are both gateway capabilities this handler is built to absorb. Two infrastructure-layer controls, both isolated behind one file so a gateway API change is a one-file fix. The failure direction is a per-category property of the guardrail, not a property of the gateway: the one flagged category does not block at all — it observes and logs — while the thirteen blocking categories refuse the request if their evaluation is unavailable. Two opposite failure directions under one toggle, because the direction follows the per-category mode. The tool boundary is enforced in the coach's own code, and the enforcement is a specific mechanism, not a description: the agent's three learning-state write tools are latched off for the rest of a turn the moment that turn has ingested an external doc-search result, checked before dispatch regardless of what order the model requests its tools in — closing the exact array-ordering bypass a manipulated tool sequence would otherwise use. Least privilege and bounded agency, not a content filter, is what does the work OWASP LLM06 asks for.

The control that actually covers the coach's streamed output is a first-party one: an OutputScanner in src/lib/promptSafety.ts scans every chunk against a fixed canary and key-leak list, holding back the tail of the buffer until enough characters have arrived to prove it isn't the start of a match — a naive per-chunk check would let a canary split across two chunks leak both halves before either half ever sits in one buffer together. A hit wipes the whole reply for the learner, not just the offending fragment. Both this and the capability latch are deterministic, checked in code rather than inferred by a model — the same category the tool boundary belongs to. The newest addition closes a gap in that stack rather than adding another filter: every one of these security decisions — a blocked state-change attempt, a dropped disallowed doc host, an injection-pattern hit, a memory-probe hit, a blocked output — now fires a durable row to a D1 audit-log table, best-effort, so a failed write degrades the record and never the control (five distinct signal types across seven call sites in the code, since the doc-search tools each fire two of those signals once per host). Before this, several of these decisions reached only console.warn — real in the moment, gone the instant the log line scrolled past. A guardrail decision nobody can query afterward is a control you cannot prove ran; the audit table is what turns "we believe this blocked it" into a row you can pull up.

Read that stack back and one thing is missing: every first-party control in it sits on the output or on the tool boundary. Nothing of ours screened what the learner sent in — the input layer of the diagram was the vendor's gateway alone. Since 2026-07-25 it is not. A policy-based classifier gate in src/lib/injectionJudge.ts runs inside runCoachLoop (src/lib/coach.ts) before the tutor model is called at all, on openai/gpt-oss-safeguard-20b — an open-weight safety classifier reached through OpenRouter on a dedicated safeguard role in src/lib/llm.ts. The part worth copying is what its system message holds: a policy document — instructions, definitions, decision criteria — rather than a prompt. The classifier is told the rules and applies them, which means changing what counts as an override attempt is an edit to that text, not a retraining run. Its scope is deliberately narrow: it sees only the learner's own recent turns, the prior learner messages plus the fresh one. It never sees the coach's replies or the lesson content — which is precisely why it cannot repeat the failure at the top of this section, where a control flagged the platform's own security curriculum. On a verdict of attempted override the turn is blocked before the tutor call, the learner gets a decline, the turn is not persisted, and a durable audit row is written.

Its posture is fail-open: if the judge is unavailable the turn proceeds, and a circuit breaker opens after three consecutive failures for a 60-second cooldown so an outage costs one wait rather than one per turn. Fail-open is the wrong default for a load-bearing control, and the argument for it here is that this one is not load-bearing. It is a context-aware second opinion layered on top of deterministic controls that do not depend on it — the capability latch, the output scanner, the tool boundary — all of which still hold with the judge switched off entirely. Failing closed would mean a classifier outage denies every learner their tutor: a certain, total loss of the product traded for a marginal gain against an attacker who still has to get past the layers underneath. Choosing a failure direction is a per-layer decision, the same as choosing flag or block, and it turns on whether the layer is the thing holding the weight.

One control in that stack is older than the judge and easy to leave off a diagram, because it screens nothing: sanitization. The coach's user turn carries system-computed metadata in band — a (Mastery: …) or (Context: …) marker the model reads as the server talking, in the same text channel the learner writes into. A learner message that opened with the literal marker prefix therefore produced two markers in one turn, one forged and one real, with nothing in the code guaranteeing the model treated the later real one as authoritative. We bounded the blast radius rather than assuming it: at most that turn's teaching depth and tone, because grade_attempt and schedule_review read the database and never parse the marker. The fix is neutralizeMarkers() in src/lib/promptSafety.ts, stripping the reserved prefixes from the raw learner message before the system appends its own, so only a system-appended marker can occur in that shape. The same channel had a second entrance: itemId and lessonId arrived as client fields and were interpolated directly into the trusted wrapper, bypassing the learner-text sanitizer entirely, so a crafted id could forge (Mastery: all objectives mastered) and collapse the coach's scaffolding. Both are now strict slugs validated in src/lib/coachWire.ts, and a malformed id is dropped rather than the message rejected. Two entrances, two fixes, one lesson: an in-band marker is a privilege claim written in the same alphabet as the data, so every path that can write into that channel is part of the boundary. And sanitizing structure is not screening intent — the judge decides whether a turn is hostile, neutralizeMarkers() only guarantees the turn cannot forge the server's own voice. Neither substitutes for the other, which is why the input layer here is two controls rather than one.

The failure that has cost us most, though, was not a layer failing. It was a lane with no layer on it. A public build-in-public post shipped with an idiom — receipts — that a non-native senior reader stumbles over. It passed the author, and it passed the automated commercial-claims gate correctly, because voice was never that gate's lane. The owner caught it on read. No amount of tuning the layers we had would have found it, because the audit question we were asking was is each layer working. The question that finds this class is the other one: what does no gate check? Run the inventory over lanes, not over layers. The inverse bites too — a control nobody labelled a control. One row in our billing dashboard reads as a legacy complimentary-access placeholder, which is to say as a pricing artifact; it is also a live entitlement boundary, and a repricing pass that treated it as pricing would have silently granted or revoked access for everyone holding it. It is now marked never-reprice and never-repurpose, and the paid cohort it resembled got a plan of its own instead. A layer inventory that counts only the things named "guardrail" will miss the ones that were named something else.

Changelog

  • 2026-07-13 — Initial publication.
  • 2026-07-21 — Corrected Cloudflare Guardrails to its real three-way setting (flag / ignore / block, configured independently for prompts and responses, on Llama Guard 3 8B) and added the scope limit that Guardrails does not evaluate streaming responses. Added as-built evidence for the deterministic controls that actually cover the coach's streamed output and tool boundary — the turn-scoped capability latch and a streaming output scanner with a chunk-boundary hold-back window — plus new security-event audit logging to a D1 audit_log table, which closes the gap where a guardrail decision previously reached only console.warn.
  • 2026-07-25 — Upgraded the streaming scope limit from a documentation claim to a first-party measurement (four live calls through the authenticated gateway: first token at 1ms against 2032ms of total generation, so nothing was buffered for a response-side check to inspect). Added the input-side first-party control the as-built stack was missing — a policy-based classifier gate (src/lib/injectionJudge.ts, openai/gpt-oss-safeguard-20b on a dedicated safeguard role) that resolves before the tutor call, judges only the learner's own turns, and fails open behind a circuit breaker. Added a section on why the rejected concurrent design — classify alongside the model call, redact the stream on a bad verdict — is monitoring rather than enforcement.
  • 2026-07-26 — Corrected the as-built infrastructure paragraph: which spend-limit rules are configured on our gateway is dashboard state, not repo state, so the page claims no specific rule, and the failure-direction contrast is now scoped to the guardrail categories where the mode actually sets it.
  • 2026-08-10 — Added two as-built passages: the in-band marker channel (a learner message could forge the server's own (Mastery: …) metadata, and a second entrance existed through unvalidated itemId/lessonId fields) with the distinction between sanitizing structure and screening intent; and the layer-inventory question — the lane no gate owned (voice) and the control nobody had labelled a control (a billing plan slug that is a live entitlement boundary).
  • 2026-08-15 — Named agents in the opening paragraph alongside LLM systems; the pattern name, id and URL are unchanged.
Sources & provenance
  • Threat taxonomy — LLM01 Prompt Injection (direct and indirect), LLM06 Excessive Agency, and the current numbering: the OWASP Top 10 for LLM Applications (2025), OWASP GenAI Security Project, checked 2026-07-13.
  • Cloudflare infrastructure layer — Llama Guard 3 8B content evaluation, the flag/ignore/block setting configured independently for prompts and responses, all-requests logging, and fail-open (flag) vs. fail-closed (block) behavior on an evaluation failure: AI Gateway Guardrails docs and the Guardrails launch post; the streaming-not-supported scope limit: Guardrails usage considerations; inline edge blocking + DLP: Firewall for AI, checked 2026-07-21.
  • AWS input/output layer — content filters, denied topics, PII block/anonymize, prompt-attack detection, ApplyGuardrail API, contextual grounding: Amazon Bedrock Guardrails documentation (feature set verified against the curriculum's 2026-06-27 review).
  • Anthropic model layer — system-prompt hardening and treating tool/retrieved content as untrusted data: Claude prompt-engineering docs; prompt-injection posture: Claude Code security docs.
  • As-built — flag/ignore/block tuning, the 424 (codes 2016/2017) and 429 typed-error handling, the turn-scoped capability latch, the streaming output scanner with its chunk-boundary hold-back window, and the security-event audit log are aiArch's own production system: src/lib/llm.ts (GuardrailBlockedError, BudgetExhaustedError), src/lib/coach.ts, src/lib/tools.ts, src/lib/promptSafety.ts (OutputScanner), and src/lib/audit.ts / db/migrations/0018_audit_log.sql, stated as first-person practice.
  • As-built — in-band marker forgery and its two entrances (neutralizeMarkers() in src/lib/promptSafety.ts; strict-slug wire validation in src/lib/coachWire.ts) are this platform's own, from the 2026-07-04 and 2026-07-23 fixes in our engineering changelog. The uncovered-lane example (a voice defect no automated gate owned) is from our 2026-07-16 session post-mortem; the unlabelled-control example (the founder plan) is a 2026-07-12 entry in our own decision record. All stated as first-person practice.
  • As-built — the input-side classifier gate, its policy-document system message, its learner-turns-only scope, and its fail-open circuit breaker: src/lib/injectionJudge.ts and the sequential gate in src/lib/coach.ts (runCoachLoop), on the safeguard role in src/lib/llm.ts. The streaming measurement (first token 1ms, total generation 2032ms, four live authenticated-gateway calls) was taken on this platform's own coach traffic, 2026-07-24.

Vendor guardrail feature sets and OWASP numbering both drift — re-verify the Cloudflare and Bedrock capability lists and the LLM Top 10 ordering before relying on anything newer than this review. Corrections: hello@aiarch.dev.

Learn to design the safety architecture, not just wire a filter.

aiArch teaches AI safety, guardrails, and least-privilege agent design by building — on a platform whose own gateway guardrails, typed error handling, and bounded coach loop run this pattern in production.

Free sample — no signup · every claim cited · full curriculum is waitlist-only