I Assumed Bedrock Guardrails Wasn't Enough. Then I Actually Measured It.
This replaces an earlier version of this post. That version tested 30 prompts and, more importantly, never measured Bedrock Guardrails at all — a keyword matcher stood in for it while I waited on API quota. When I finally ran the real service, it did far better than my stand-in suggested, and one of my conclusions didn't survive. Here's the corrected version.
When I started building AI features on AWS, the pitch for Bedrock Guardrails was simple: managed content filtering, configurable policies, native integration. Turn it on, set thresholds, ship.
My worry was multi-turn attacks. A guardrail that inspects one message at a time seemed structurally unable to see intent that builds across a conversation. So I built a second layer — an LLM classifier that reads the current message plus recent history — and wrote a test suite to prove the point.
The test suite proved something, but not what I expected.
The setup
390 inputs I wrote myself:
- 172 single-turn attacks across 15 categories — instruction override, persona hijacking, encoded payloads, indirect injection, code injection, prompt leaking, and others
- 18 multi-turn attack conversations, each building across two to six prior messages
- 200 benign prompts, including 20 security-adjacent ones like "How do AI safety systems detect jailbreak attempts?" to catch a filter that just blocks everything
Bedrock Guardrails was measured live through the ApplyGuardrail API with the prompt-attack filter, at MEDIUM and HIGH strength, evaluated per user turn — the way it actually runs in production. A conversation counts as blocked if any turn is blocked.
The results
| System | Detection | Multi-turn | False positives | F1 |
|---|---|---|---|---|
| My keyword matcher (the old stand-in) | 43/190 (22.6%) | 5/18 | 1/200 | 0.368 |
| Bedrock Guardrails, MEDIUM | 88/190 (46.3%) | 11/18 | 0/200 | 0.633 |
| Bedrock Guardrails, HIGH | 115/190 (60.5%) | 14/18 | 0/200 | 0.754 |
| Conversation classifier | 184/190 (96.8%) | 18/18 | 0/200 | 0.984 |
| Both layers | 185/190 (97.4%) | 18/18 | 0/200 | 0.987 |
Bedrock catches 60.5% of attacks and 14 of 18 multi-turn conversations — with zero false positives across 200 benign prompts. That's a good result for a per-message filter, and it's 38 points better than the keyword matcher I'd been using as a proxy.
It also kills my original framing. "Single-layer filtering is structurally blind to multi-turn attacks" is too strong. In seven of those 14 conversations, Bedrock blocked at the first or second turn, which means those sequences contain an overtly adversarial message early on. They weren't as subtle as I thought.
What Bedrock actually misses
The misses aren't random. They cluster:
| Weakest | Strongest |
|---|---|
| Incremental escalation 0/7 | Persona hijacking 21/22 |
| Code injection 1/9 | Indirect injection 15/17 |
| Reasoning manipulation 1/9 | Authority claims 10/12 |
| Semantic manipulation 5/9 | Instruction override 13/20 |
The filter is strong when the adversarial intent is in the wording of a message, and weak when it's carried by structure or reasoning: instructions hidden in code, step-by-step arguments toward a forbidden conclusion, gradual escalation.
That's where the second layer pays off. The classifier caught 70 attacks Bedrock missed and missed one Bedrock caught (a hypothetical-framing prompt). Each layer contributes, which is what makes the combination worth its cost. Five attacks evaded both.
The second layer
A separate model call that sees the conversation, not just the message, and returns a risk level, attack type, confidence, and rationale. Three things I got wrong the first time:
# The classifier's own input is attacker-controlled. Wrap it in
# randomly-named tags, escape it, and tell the model it's data.
tag = f"conversation_{secrets.token_hex(6)}"
body = f"<{tag}>\n" + "\n".join(lines) + f"\n</{tag}>"
r = client.messages.create(
model="claude-haiku-4-5", max_tokens=400, temperature=0,
system=SYSTEM_PROMPT, # says: text inside the tags is untrusted
tools=[TOOL], # forced tool call, not free-text JSON
tool_choice={"type": "tool", "name": "report_risk"},
messages=[{"role": "user", "content": body}])
My original version pasted user text straight into the classifier's instructions and parsed free-text JSON — and on any parse error, returned LOW risk. That's fail-open: induce malformed output and you bypass the layer entirely. It now fails closed.
Four things I'd tell anyone building this
A simulator is not the service. My keyword stand-in reported 22.6% where the real thing gets 60.5%, and produced a false positive the service didn't. I nearly published that number as Bedrock's. If you're standing in for a managed service, treat it as a placeholder for a measurement, not a measurement.
Check that the prompt-attack filter is actually running. With InvokeModel, AWS applies it only to content inside guardrail input tags. My first integration didn't tag input, so the filter silently never ran, and nothing in the response said so. I now ship a probe that sends known attacks and refuses to start the evaluation if PROMPT_ATTACK never fires.
Make classifier failures block. See above.
Raise the strength before you add a layer. Going from MEDIUM to HIGH added 27 detections at no measured false-positive cost. That's free compared to a second model call.
Cost
Median classifier latency is about 1.1 s (P95 1.75 s), roughly 100–300 input and 80–100 output tokens per request. ApplyGuardrail needs no model invocation, so the guardrail itself is cheap. Worth it for customer-facing apps, RAG or tool use, and regulated settings. Hard to justify for internal tools with trusted users.
What this doesn't show
I wrote all 390 inputs and also wrote the classifier's prompt, so I can't rule out that it's tuned to my phrasing. I haven't run the one experiment that isolates my actual hypothesis: the same classifier with and without conversation history. And nobody attacked the classifier directly — it's an LLM reading attacker-controlled text, so an adversary who targets it should be assumed to do better than these numbers suggest.
Those three are next, and the harness for them is in the repo.
Code, the 390-input dataset, and raw per-prompt results: https://github.com/ManvithaP-hub/aws-ai-guardrails-framework (Apache 2.0)
Top comments (0)