Invoice automation is one of those problems that sounds simple — “just read the PDF and pull out the fields” — until you actually try to do it reliably at scale. In our case, the pipeline looks like this: documents land in S3, Amazon Textract OCRs them, and a Lambda function hands the extracted text to Claude 4.5 on Amazon Bedrock to pull out structured fields like vehicle make, model, VIN, and registration details.
The OCR and prompting parts were the easy bit. The part that actually cost us debugging time was IAM — specifically, getting the permissions right for a Bedrock inference profile. This post walks through the setup and the gotcha that trips most people up the first time.
1. The Pipeline, in Short
S3 (incoming documents)
└─▶ Textract (async OCR)
└─▶ S3 "textract-output/" prefix (raw OCR JSON)
└─▶ Lambda: extractVehicleDetails
└─▶ Bedrock Converse API (Claude 4.5)
└─▶ Structured vehicle details (JSON)
The extractVehicleDetails Lambda reads Textract’s output for a document, sends the extracted text to Claude with a prompt describing the fields we want, and gets back structured JSON. Nothing exotic — until you wire up the IAM role.
2. Why an Inference Profile Instead of a Model ARN
When you call Bedrock directly against a foundation model, you’re pinned to a single region. Cross-region inference profiles sit in front of the model and route your request to whichever backing region has capacity — useful for throughput, resilience, and staying within quota limits when you’re running production traffic through Claude.
Instead of calling the model directly, your code calls the profile:
arn:aws:bedrock:eu-central-1:123456789012:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0
The profile then dispatches the actual inference request to one of its underlying foundation models, which might live in eu-north-1, eu-west-1, or eu-central-1 depending on where capacity is available at that moment. Note the eu. prefix on the profile ID itself — that’s what marks it as a cross-region profile scoped to the EU, as opposed to a single-region model call.
3. The Gotcha: One ARN Is Not Enough
Here’s the part that isn’t obvious from the docs: your Lambda’s code only ever references the inference profile ARN. But IAM authorization doesn’t stop there — Bedrock evaluates permissions against the actual foundation model the profile routes to as well. Grant permission only on the profile ARN, and the first time your profile happens to route to a model in a region you didn’t explicitly allow, you’ll hit:
AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/extractVehicleDetails-role/extractVehicleDetails
is not authorized to perform: bedrock:InvokeModel on resource:
arn:aws:bedrock:eu-north-1::foundation-model/anthropic.claude-sonnet-4-5-20250929-v1:0
because no identity-based policy allows the bedrock:InvokeModel action
The frustrating bit is that this can pass testing and then fail intermittently in production, purely because the profile happened to route somewhere your policy didn’t cover. The fix is to grant both: the profile ARN as the entry point, and the underlying foundation model ARN — with a wildcard region — as the actual execution target.
4. The Serverless Framework Setup
Here’s the full iamRoleStatements block for the function, as we run it:
extractVehicleDetails:
handler: src/handlers/documents/extract-details.handler
timeout: 300
iamRoleStatementsName: ${param:shorthandServiceName}-${aws:region}-${sls:stage}-extractDetails
iamRoleStatements:
- Effect: Allow
Action:
- s3:ListBucket
Resource:
- !GetAtt EmailAndDocumentStore.Arn
Condition:
StringLike:
s3:prefix:
- 'textract-output/*'
- Effect: Allow
Action:
- s3:GetObject
Resource:
- !Sub '${EmailAndDocumentStore.Arn}/textract-output/*'
- Effect: Allow
Action:
- bedrock:Converse
- bedrock:InvokeModel
Resource:
# 1. Permission for the specific Inference Profile (The entry point)
- '${env:BEDROCK_PROFILE_ARN}'
# 2. Future-proof permission for the underlying Foundation Model (The execution)
# We use '*' for the region to cover eu-north-1, eu-central-1, etc.
- '${env:BEDROCK_MODEL_ARN}'
environment:
BEDROCK_PROFILE_ARN: arn:aws:bedrock:eu-central-1:123456789012:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0
BEDROCK_MODEL_ARN: arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-5-20250929-v1:0
Two things to notice:
- The profile ARN carries your account ID and a specific region (
eu-central-1, wherever you provisioned the profile), plus theeu.prefix on the model ID marking it as a cross-region EU profile. - The model ARN has no account ID at all — foundation models are AWS-owned, shared resources, not something in your account — and the region is a bare
*, since the profile can dispatch toeu-north-1,eu-west-1, oreu-central-1depending on capacity, and you don’t know which in advance.
A few more things worth calling out:
✅ Bedrock permissions
- Two actions, not one — we allow both
bedrock:Converseandbedrock:InvokeModel. Converse is the newer, unified API (multi-turn, system prompts, tool use) and is what our handler actually calls, but grantingInvokeModeltoo keeps us compatible if any code path — or a future SDK upgrade — falls back to the raw invoke call. - Two resources, not one —
BEDROCK_PROFILE_ARNcovers the inference profile itself, andBEDROCK_MODEL_ARN(with a wildcard region) covers whatever foundation model the profile actually dispatches to. Skip the second one and you get theAccessDeniedExceptionfrom above, sooner or later. - ARNs live in environment variables, not hardcoded in the policy or the handler code. Inference profile IDs differ per account and can change if we swap models or regions, so this keeps the IAM policy and the Lambda code in sync without a redeploy of application logic.
✅ S3 permissions, scoped tight
s3:ListBucketis scoped with aStringLikecondition ons3:prefix, so the role can only list keys undertextract-output/*— not the whole bucket, which also holds the original incoming documents and other pipeline artifacts.s3:GetObjectis scoped the same way, directly on thetextract-output/*path.
Neither statement grants write or delete access, since this function only ever reads Textract’s output.
5. Takeaways
If you’re wiring up Lambda + Bedrock inference profiles for the first time, the short version is:
- Always grant IAM permissions on both the inference profile ARN and the underlying foundation model ARN (with a wildcard region) — the profile is just a router, not the thing that ultimately gets authorized.
- Grant
bedrock:Converseandbedrock:InvokeModeltogether unless you’re certain your code — and everything it might call in the future — only ever uses one. - Pass ARNs through environment variables so a model or region change doesn’t require touching your IAM policy by hand.
- Scope S3 access to the specific prefix your function actually reads from, especially when the bucket is shared across stages of a document pipeline.
Get the IAM right once, and the rest of the Textract → Bedrock → structured JSON pipeline is refreshingly boring — which, for production infrastructure, is exactly what you want.