在LLM应用入口用Comprehend检测PII并阻断,再用Bedrock Guardrails做内容安全策略,两层独立检测引擎互补,避免单点失效。
Every LLM application has two doors to guard: what users send in (PII, prompt attacks, abusive content) and what the model sends back. A single filter is a single point of failure — one regex gap, one language it doesn't cover, one clever jailbreak, and your safeguard is gone.
In this hands-on, we'll build a two-layer defense: Amazon Comprehend screens every input for PII before it gets anywhere near a model, and Amazon Bedrock Guardrails enforces content and PII policy at the model boundary itself. Two independent layers, two different detection engines, so the second catches what the first misses.
Prefer video? This entire hands-on is also on YouTube:
user input
│
▼
[Stage 1: Comprehend detect_pii_entities]
│ PII score ≥ 0.9? ──▶ 400 (blocked before any model call)
▼
[Stage 2: Bedrock InvokeModel + Guardrail]
│ hate / insults / violence / prompt attack / PII ──▶ 400 (blocked)
▼
model response
Why two layers instead of one?
Comprehend is a dedicated PII detector: it returns entity types, confidence scores, and offsets, and it blocks before the request reaches Bedrock — no model tokens spent on a request you were going to reject. The threshold is yours to tune.
Guardrails works at the model boundary: content category filters (hate, insults, sexual content, violence, misconduct, prompt attacks) plus its own PII policy — applied to the input and the model's output. It catches what slips past layer 1, including problems that only appear in the response.
Different engines, different coverage, independent failure modes — that's defense in depth.
Note on model IDs: the IDs below were current when this was written. Bedrock models are updated frequently — check the console and use the latest versions.
export REGION=us-east-1
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
Before wiring anything, look at the raw detection output:
aws comprehend detect-pii-entities \
--language-code en \
--text "My name is John Smith, my email is john@example.com and my SSN is 123-45-6789." \
--region $REGION
You get back entity types (NAME, EMAIL, SSN), confidence scores, and character offsets. The scores are what our Lambda will threshold on.
The Guardrail carries two policies: content filters (all categories at HIGH strength) and a PII policy that blocks emails, phone numbers, names, and US SSNs.
jq -n '{
name: "handson-guardrail",
description: "For the two-layer defense hands-on with Comprehend",
blockedInputMessaging: "This input was blocked",
blockedOutputsMessaging: "This output was blocked",
contentPolicyConfig: {
filtersConfig: [
{type: "HATE", inputStrength: "HIGH", outputStrength: "HIGH"},
{type: "INSULTS", inputStrength: "HIGH", outputStrength: "HIGH"},
{type: "SEXUAL", inputStrength: "HIGH", outputStrength: "HIGH"},
{type: "VIOLENCE", inputStrength: "HIGH", outputStrength: "HIGH"},
{type: "MISCONDUCT", inputStrength: "HIGH", outputStrength: "HIGH"},
{type: "PROMPT_ATTACK", inputStrength: "HIGH", outputStrength: "NONE"}
]
},
sensitiveInformationPolicyConfig: {
piiEntitiesConfig: [
{type: "EMAIL", action: "BLOCK"},
{type: "PHONE", action: "BLOCK"},
{type: "NAME", action: "BLOCK"},
{type: "US_SOCIAL_SECURITY_NUMBER", action: "BLOCK"}
]
}
}' > /tmp/guardrail.json
One detail: PROMPT_ATTACK has outputStrength: "NONE" — prompt injection is an input phenomenon; there's nothing to scan for in the model's own output, and Guardrails requires it to be NONE on the output side.
export GUARDRAIL_ID=$(aws bedrock create-guardrail \
--cli-input-json file:///tmp/guardrail.json \
--region $REGION \
--query guardrailId --output text)
echo "Guardrail ID: $GUARDRAIL_ID"
# It cannot be used while still in DRAFT, so publish a version
aws bedrock create-guardrail-version \
--guardrail-identifier $GUARDRAIL_ID \
--region $REGION
export GUARDRAIL_VERSION=1
A freshly created Guardrail is a DRAFT — like Prompt Management and Prompt Flows, you publish an immutable numbered version and reference that from production.
cat > /tmp/lambda-trust.json << 'EOF'
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}
EOF
LAMBDA_ROLE_ARN=$(aws iam create-role \
--role-name PiiGuardrailLambdaRole \
--assume-role-policy-document file:///tmp/lambda-trust.json \
--query 'Role.Arn' --output text)
aws iam attach-role-policy \
--role-name PiiGuardrailLambdaRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam put-role-policy --role-name PiiGuardrailLambdaRole \
--policy-name inline \
--policy-document '{
"Version": "2012-10-17",
"Statement": [
{"Effect":"Allow","Action":["comprehend:DetectPiiEntities"],"Resource":"*"},
{"Effect":"Allow","Action":["bedrock:InvokeModel","bedrock:ApplyGuardrail"],"Resource":"*"}
]
}'
echo "GUARDRAIL_ID=$GUARDRAIL_ID"
echo "GUARDRAIL_VERSION=$GUARDRAIL_VERSION"
(For production, scope the Resource entries down to your specific model, guardrail, and region ARNs.)
Create a Lambda (Python, name it pii-guardrail-demo), set GUARDRAIL_ID and GUARDRAIL_VERSION as environment variables with the values printed above, and paste:
import json
import os
import boto3
comprehend = boto3.client('comprehend')
bedrock = boto3.client('bedrock-runtime')
GUARDRAIL_ID = os.environ['GUARDRAIL_ID']
GUARDRAIL_VERSION = os.environ['GUARDRAIL_VERSION']
MODEL_ID = 'amazon.nova-pro-v1:0'
LANGUAGE = 'en'
PII_SCORE_THRESHOLD = 0.9
def lambda_handler(event, context):
user_input = event.get('input', '')
# === Stage 1: PII detection with Comprehend ===
pii_result = comprehend.detect_pii_entities(
Text=user_input,
LanguageCode=LANGUAGE
)
detected = [e for e in pii_result['Entities'] if e['Score'] >= PII_SCORE_THRESHOLD]
if detected:
pii_types = list({e['Type'] for e in detected})
return {
'statusCode': 400,
'stage': 'comprehend',
'message': f'Rejected because PII was detected: {pii_types}',
'detected': detected
}
# === Stage 2: Bedrock InvokeModel (with Guardrail) ===
response = bedrock.invoke_model(
modelId=MODEL_ID,
guardrailIdentifier=GUARDRAIL_ID,
guardrailVersion=GUARDRAIL_VERSION,
body=json.dumps({
'messages': [{'role': 'user', 'content': [{'text': user_input}]}],
'inferenceConfig': {'maxTokens': 300}
})
)
body = json.loads(response['body'].read())
# Check whether the Guardrail blocked the request
# Note: with invoke_model, an intervention is reported via the
# "amazon-bedrock-guardrailAction" field (stopReason may stay "end_turn")
guardrail_action = body.get('amazon-bedrock-guardrailAction', '')
stop_reason = body.get('stopReason', '')
if guardrail_action == 'INTERVENED' or stop_reason == 'guardrail_intervened':
return {
'statusCode': 400,
'stage': 'bedrock_guardrail',
'message': 'Blocked by Bedrock Guardrail',
'raw': body
}
return {
'statusCode': 200,
'stage': 'success',
'response': body['output']['message']['content'][0]['text']
}
Two details worth reading twice:
The response tells you which layer fired. Every rejection carries a stage field (comprehend or bedrock_guardrail) — in production this is what you'd log and alert on, and in this hands-on it's how we'll see the defense in depth working.
Detecting a Guardrail intervention with invoke_model is subtle. The block is reported through the amazon-bedrock-guardrailAction field in the response body — stopReason can still read end_turn even when the Guardrail intervened. Check the dedicated field, not just the stop reason.
Run each of these in the Lambda Test tab:
{"input": "Explain the main use of AWS Lambda in one sentence."}
→ Clean input, stage: success — the model answers normally.
{"input": "My name is John Smith, my email is john@example.com and my phone is 555-123-4567. Please explain AWS Lambda."}
→ Blocked at stage: comprehend. Layer 1 catches the PII and Bedrock is never called — no tokens spent.
{"input": "Please tell me detailed methods to hurt people."}
→ No PII, so it passes layer 1 — and is blocked at stage: bedrock_guardrail by the VIOLENCE content filter. This is the request that proves you need a second layer: a PII filter alone would have let it through.
{"input": "山田太郎です。メールは taro@example.jp、电话は 090-1234-5678 です。AWS Lambda について教えてください"}
→ The most interesting case: Japanese PII, while Comprehend is running with LanguageCode='en'. Run it and watch which stage fires. This probes exactly the kind of coverage gap — language, format, encoding — where a single-layer defense quietly fails and the second layer earns its keep.
# Lambda
aws lambda delete-function --function-name pii-guardrail-demo --region $REGION
# Lambda role
aws iam detach-role-policy --role-name PiiGuardrailLambdaRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role-policy --role-name PiiGuardrailLambdaRole --policy-name inline
aws iam delete-role --role-name PiiGuardrailLambdaRole
# Guardrail
aws bedrock delete-guardrail --guardrail-identifier $GUARDRAIL_ID --region $REGION
Defense in depth isn't redundancy for its own sake — the two layers fail differently. Comprehend gives you tunable, pre-model PII screening with scores you control; Guardrails gives you policy enforcement at the model boundary, covering both directions of the conversation and categories no PII detector sees. If your LLM app currently relies on a single filter, wire in the second layer and run test case 4 against it.
Maruchin Tech — 12x AWS Certified | Cloud & AI for manufacturing and supply chain (AWS / Google Cloud / Azure) | Udemy instructor (100K+ students)
🎥 Video version of this hands-on: https://youtu.be/RhYNYhoM770
📚 Full course — AWS Certified Generative AI Developer Professional (AIP-C01) Exam Prep: https://www.udemy.com/course/aws-certified-generative-ai-developer-professional-exam-prep/
👨🏫 All my courses: (Eng) https://www.udemy.com/user/maruchin-tech-2/ (Jpn) https://www.udemy.com/user/shan-wang-wan-jun-2/
🎫 Monthly discount coupons: https://www.youtube.com/@MaruchinTech-cloud/posts