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 the eu. 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 to eu-north-1, eu-west-1, or eu-central-1 depending 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:Converse and bedrock:InvokeModel. Converse is the newer, unified API (multi-turn, system prompts, tool use) and is what our handler actually calls, but granting InvokeModel too keeps us compatible if any code path — or a future SDK upgrade — falls back to the raw invoke call.
  • Two resources, not oneBEDROCK_PROFILE_ARN covers the inference profile itself, and BEDROCK_MODEL_ARN (with a wildcard region) covers whatever foundation model the profile actually dispatches to. Skip the second one and you get the AccessDeniedException from 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:ListBucket is scoped with a StringLike condition on s3:prefix, so the role can only list keys under textract-output/* — not the whole bucket, which also holds the original incoming documents and other pipeline artifacts.
  • s3:GetObject is scoped the same way, directly on the textract-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:Converse and bedrock:InvokeModel together 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.