Zuplo
On this page
MCP error

SigV4 signature fails to verify

The string your client printed
The request signature we calculated does not match the signature you provided

AWS recomputed the signature over the request it actually received, got a different value from the one you sent, and answered 403 before your MCP server ran — so nothing about your tools, your handler, or your IAM policy is implicated yet.

MCP specification
2026-07-28

Is this you?

Sign one request with a known-good signer and read the body AWS sends back, then prove the credentials and the clock separately. That splits the three things this message covers: a wrong service or Region, a canonical request that differs from what arrived, and credentials that were never going to work.

TerminalRun this to check — it changes nothing
# 1. Sign exactly the request your client sends, and print the response body.
#    AWS names the element that did not match.
python3 - <<'PY'
import boto3, requests
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest

url = "https://url-id-12345.lambda-url.us-west-2.on.aws/mcp"
# The signing name of whatever fronts the server: "lambda" for a function
# URL, "execute-api" for API Gateway, "bedrock-agentcore" for AgentCore.
region, service = "us-west-2", "lambda"
body = '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

req = AWSRequest(method="POST", url=url, data=body, headers={
    "Content-Type": "application/json",
    "Accept": "application/json, text/event-stream",
})
# data= is the whole point: without it the payload hash is the
# SHA-256 of the empty string and this reproduces the bug instead of
# testing for it.
SigV4Auth(boto3.Session().get_credentials(), service, region).add_auth(req)

r = requests.post(url, data=body, headers=dict(req.headers))
print(r.status_code, r.headers.get("content-type"))
print(r.text)
print("SignedHeaders:",
      req.headers["Authorization"].split("SignedHeaders=")[1].split(",")[0])
PY

# 2. Are the credentials sound at all? This is itself a SigV4 request signed
#    by a known-good implementation, which is AWS's own advice when a
#    signature will not verify.
aws sts get-caller-identity

# 3. Is the clock right? Compare yours with the one AWS stamps on a response.
date -u +'%Y-%m-%dT%H:%M:%SZ'
curl -sS -D - -o /dev/null https://sts.us-east-1.amazonaws.com/ | grep -i '^date:'
This error
A 403 whose body carries The request signature we calculated does not match the signature you provided is this page. A 403 whose body is Credential should be scoped to correct service: '<service>' or Credential should be scoped to a valid Region, not <region-code> is the same page with the answer already in it — change that one value. Signature expired: <date> is now earlier than <date> is a clock or a request that sat in a retry queue, not a signing bug. Three responses are none of the above and need a different fix: Missing Authentication Token, which means the request arrived unsigned; a 403 carrying AccessDeniedException or AgentCore's ACCESS_DENIED, which means the signature verified and the IAM policy said no — on a new function URL that needs both lambda:InvokeFunctionUrl and lambda:InvokeFunction; and a 403 from step 2 as well as step 1, which means the credentials or the clock are wrong for every AWS call you make and the MCP endpoint is incidental.
Then the fix is the next section.
Working
Step 1 prints 200 with application/json or text/event-stream and a JSON-RPC result, and a SignedHeaders list that starts with content-type;host;x-amz-datehost and x-amz-date are required on every signed request. Step 2 prints the ARN you expect. Step 3's two timestamps agree to within a few seconds. If step 1 succeeds while your MCP client still fails, the fault is entirely in your client's signing path and the platform tiles are the place to look.
Then this isn't your problem — try the error reference.
7 other strings clients print for this
  • SignatureDoesNotMatch
  • Credential should be scoped to correct service: '<service>'
  • Credential should be scoped to a valid Region, not <region-code>
  • Signature expired: <date> is now earlier than <date>
  • Signature not yet current: <date> is still later than <date>
  • Date in Credential scope does not match YYYYMMDD from ISO-8601 version of date from HTTP
  • The signature contains an invalid key=value pair (missing equal-sign) in Authorization header

Dotted spans such as <url> are placeholders — your client prints your own value there.

Fix it

Read the response body, not your client's exception: AWS names the element that did not match. Credential should be scoped to correct service: '<service>' means the signing name — whatever fronts the server, so lambda for a Lambda function URL, execute-api for API Gateway, bedrock-agentcore for Bedrock AgentCore, never mcp. Credential should be scoped to a valid Region means the Region in that scope; Signature expired means a clock or a queued retry, not a signing bug. A bare mismatch with all three right means the canonical request itself differs, and for MCP that is almost always the payload hash — every JSON-RPC message is a POST with a body, and a signer handed no body signs the SHA-256 of the empty string. Then look for a hop that changed host or another signed header after signing.

Have your agent fix this

Copy a prompt that sends your coding agent to this page to find and apply the right fix for your project.

The cause depends on your platform. Pick yours.

MCP Python SDK (botocore signer)

If the signing happens in Python — aws_iam_streamablehttp_client, streamablehttp_client_with_sigv4, or your own SigV4Auth

  1. 1 Check the body reached the signer before anything else. SigV4Auth.payload() hashes request.body, and with no body it returns botocore's EMPTY_SHA256_HASHe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. Build an AWSRequest without data=, sign it, then POST the JSON-RPC message with requests or httpx, and the canonical request you signed claims an empty payload while AWS hashes the message. Every MCP call is a POST with a body, so this fails on the first request and never works once.
  2. 2 Copy the whole signed header set onto the outgoing request, not just Authorization. botocore's _modify_request_before_signing adds X-Amz-Security-Token itself when the credentials carry a session token, and that header is part of the canonical request — cherry-pick Authorization out of the signed AWSRequest and the token is silently dropped. AWS's own signing reference is explicit that temporary credentials need it: "When you use temporary security credentials, you must add X-Amz-Security-Token to the Authorization header or include it in the query string to hold the session token."
  3. 3 Pass the service name rather than letting it be inferred. aws_iam_streamablehttp_client takes aws_service and aws_region explicitly; AWS's proxy in front of it does infer, and its own troubleshooting entry for this error explains why to override that: "We try to autodetect the service from the url, sometimes this fails, ensure that --service is set correctly to the service you are attempting to connect to. Otherwise the SigV4 signing will not be able to be verified by the service you connect to, resulting in this error."
  4. 4 Don't hand botocore headers that change in flight. Its SIGNED_HEADERS_BLACKLIST already skips connection, expect, keep-alive, proxy-authenticate, proxy-authorization, te, trailer, transfer-encoding, upgrade, user-agent, and x-amzn-trace-id, and AWS's signing reference says why: "Do not include hop-by-hop headers that are frequently altered during transit across a complex system." AWS's proxy strips one by hand for exactly this reason — its comment reads "Header 'connection' = 'keep-alive' is not used in calculating the request signature on the server-side, and results in a signature mismatch if included".
  5. 5 On streamablehttp_client_with_sigv4, check the three inputs rather than the transport. Its own module docstring calls it a "Backwards compatibility wrapper" that forwards to aws_iam_streamablehttp_client from mcp-proxy-for-aws, and both take the same three: endpoint, service, and region.
python
import boto3
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest

body = '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

request = AWSRequest(
    method="POST",
    url="https://url-id-12345.lambda-url.us-west-2.on.aws/mcp",
    # Omit data= and the payload hash is the empty-string SHA-256 while
    # AWS hashes the body you actually send. This is the usual cause.
    data=body,
    headers={
        "Content-Type": "application/json",
        "Accept": "application/json, text/event-stream",
    },
)

# "lambda" here, "execute-api" for API Gateway, "bedrock-agentcore" for
# AgentCore Runtime. Never "mcp".
SigV4Auth(boto3.Session().get_credentials(), "lambda", "us-west-2").add_auth(request)

# dict(request.headers), not just the Authorization value: botocore also
# set x-amz-date and, for session credentials, X-Amz-Security-Token.
headers = dict(request.headers)
AWS: Create a signed AWS API request

MCP TypeScript SDK (SigV4 transport)

If your agent uses StreamableHTTPClientWithSigV4Transport or createSigV4Fetch

  1. 1 Check the two options that decide the credential scope, region and service. The transport's own comment names the values it expects — "AWS service name for signing requests (e.g., 'lambda', 'execute-api')" — and AgentCore adds bedrock-agentcore. Get either wrong and AWS answers with the element it wanted rather than a bare mismatch: Credential should be scoped to correct service: '<service>', or Credential should be scoped to a valid Region, not <region-code>.
  2. 2 Sign the origin host, not a front door. The signer takes the signed host from the URL you construct it with — it seeds the header map with host: urlObj.hostname — and host is required in every canonical request, so a custom domain, a load balancer, or a CDN that forwards to a different origin host means you signed the wrong host. AWS documents this exact failure for a function URL behind CloudFront: "Lambda will validate the signature against the host of the Lambda URL domain. If the signature isn't based on the Lambda URL domain, the host in the signature won't match the host used by the Lambda URL origin. This means the request will fail, resulting in a signature validation error."
  3. 3 Read the payload hash off the wire, which botocore doesn't give you. @smithy/signature-v4 sets x-amz-content-sha256 when the header is absent and applyChecksum is on, and applyChecksum is on by default — the source comment is "default to true if applyChecksum isn't set". That header is itself signed, so anything that rewrites the body after signing breaks the hash and the header together.
  4. 4 Expect every header you put on the transport to be signed unless the signer considers it unsignable, and the exempt list is fixed: ALWAYS_UNSIGNABLE_HEADERS covers authorization, cache-control, connection, expect, from, keep-alive, max-forwards, pragma, referer, te, trailer, transfer-encoding, upgrade, user-agent, and x-amzn-trace-id, plus anything matching /^proxy-/i or /^sec-/i. The MCP transport headers are not on it — mcp-protocol-version and mcp-method on every request, plus mcp-name on the methods that carry a name (tools/call, resources/read, prompts/get), all of which 2026-07-28 made mandatory — so a hop that adds or rewrites any of them after signing is a mismatch.
  5. 5 Start every re-sign from a clean header set. smithy's prepareRequest deletes authorization, x-amz-date, and date before it signs, and a caller that restores its own saved headers afterwards puts the previous signature back over the new body. LiteLLM shipped a fix for precisely that shape: a retry re-signed a modified body, the first attempt's Authorization and X-Amz-Date were restored on top, and AWS returned 403 SignatureDoesNotMatch.
TypeScriptts
import { StreamableHTTPClientWithSigV4Transport } from "@aws/run-mcp-servers-with-aws-lambda";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";

// The first argument must be a URL object. Its hostname becomes the signed
// `host` header, so it has to be the host the verifying AWS service sees —
// not a custom domain or CDN in front of it.
const transport = new StreamableHTTPClientWithSigV4Transport(
  new URL("https://url-id-12345.lambda-url.us-west-2.on.aws/mcp"),
  // `service` is the SigV4 signing name of the endpoint and `region` has
  // to match the credential scope AWS will recompute.
  { service: "lambda", region: "us-west-2" },
);

const client = new Client({ name: "my-client", version: "0.0.1" });
await client.connect(transport);
AWS Lambda MCP: SigV4 client transport

Claude Code

If a chat client is the caller and AWS's local signing proxy is doing the signing for it

  1. 1 Look in the proxy, not in your server: no MCP client signs SigV4. AWS's answer is mcp-proxy-for-aws, a local stdio process launched by the client that signs from your own credentials, so every value in this error comes from that process's arguments and environment.
  2. 2 Set --service explicitly. The flag's own documentation is "AWS service name for SigV4 signing, if omitted we try to infer this from the url", and the proxy's troubleshooting entry for this error is about that inference failing. A function URL host is URL_ID.lambda-url.REGION.on.aws, which carries no service label to infer from at all.
  3. 3 Set the Region the same way. --region "Uses AWS_REGION environment variable if not set", and a process a client spawns does not necessarily inherit your shell's exports — put the value in args, or in the config's env block. A separate symptom to recognize: no Region resolved at all surfaces as NoRegionError, which the proxy's docs note MCP clients show as -32602: Invalid request parameters, not as a signature error.
  4. 4 Rule out stale credentials, which usually are not this error. The proxy "reads fresh credentials from disk on every request, so credential refreshes and account switches take effect immediately — no restart or retry required", and expired credentials produce Signature expired: <date> is now earlier than <date> rather than a mismatch. Run aws sso login and retry without restarting anything. If the message is still the mismatch, the service or the Region is wrong.
JSONjson
{
  "mcpServers": {
    "aws-mcp": {
      "command": "uvx",
      "args": [
        "mcp-proxy-for-aws@1.6.0",
        "https://url-id-12345.lambda-url.us-west-2.on.aws/mcp",
        "--service",
        "lambda",
        "--region",
        "us-west-2",
        "--profile",
        "default"
      ]
    }
  }
}
aws/mcp-proxy-for-aws

AWS API Gateway (IAM auth)

If the MCP route is an API Gateway method whose authorizationType is AWS_IAM

  1. 1 The service code is execute-api, not apigateway and not lambda. AWS's permissions model page puts it plainly: the invoking actions are "execute-api:Invoke or execute-api:InvalidateCache, where execute-api designates the underlying API execution component of API Gateway." Sign with the wrong one and the credential-scope check fails before the signature is compared, giving you Credential should be scoped to correct service: '<service>'.
  2. 2 The stage is part of the path you sign. AWS's annotated canonical request marks the second line as "Path. For API stage endpoint, it should be /{stage-name}/{resource-path}", against a host of the form <api-id>.execute-api.<region>.amazonaws.com. A client configured with the bare invoke URL and the stage stitched on elsewhere signs a path the gateway never sees.
  3. 3 Sign the Region the API lives in, which the host already tells you. Both signature versions are accepted — "API Gateway supports Signature Version 4a (SigV4a) and Signature Version 4 (SigV4) to authenticate the user's credentials" — but the credential scope has to match either way, and with SigV4a the Region moves out of the scope and into an X-Amz-Region-Set header that is signed instead.
  4. 4 Separate this from an unsigned request. AWS: "Make sure that the API request is signed with SigV4. If the API request isn't signed, then you might receive the error: Missing Authentication Token." That is a client that never signed at all, which is a different fix from a signature that did not verify.
text
GET                                                      -------- HTTP method
/                                                        -------- Path. For API stage endpoint, it should be /{stage-name}/{resource-path}
                                                         -------- Query string key-value pair. Leave it blank if the request doesn't have a query string.
content-type:application/json                            -------- Header key-value pair. One header per line.
host:0123456789.execute-api.us-east-1.amazonaws.com      -------- Host and x-amz-date are required headers for all signed requests.
x-amz-date:20220806T024003Z

content-type;host;x-amz-date                             -------- A list of signed headers
d167e99c53f15b0c105101d468ae35a3dc9187839ca081095e340f3649a04501        -------- Hash of the payload
AWS: Troubleshoot Signature Version 4 signing

A load balancer or proxy in the path

If anything sits between the code that signs and the AWS service that verifies — a load balancer, a CDN, a service-mesh sidecar, or a gateway re-signing on your behalf

  1. 1 Start here when the same request succeeds against the AWS hostname and fails through your own. AWS's troubleshooting note is one sentence and it is the whole diagnosis: "You can also verify that you didn't send the request through a proxy that modifies the headers or the request."
  2. 2 Check what Host each hop presents. host is signed on every request — AWS requires it in the canonical headers — so a hop that presents a different Host to the verifying service breaks the signature by construction, before anything about your payload matters. AWS spells this out for a function URL behind CloudFront: "If the signature isn't based on the Lambda URL domain, the host in the signature won't match the host used by the Lambda URL origin. This means the request will fail, resulting in a signature validation error."
  3. 3 Take the volatile headers out of what you sign. AWS names them: "Do not include hop-by-hop headers that are frequently altered during transit across a complex system. This includes all volatile transport headers that are mutated by proxies, load balancers, and the nodes in a distributed system, including connection, x-amzn-trace-id, user-agent, keep-alive, transfer-encoding, TE, trailer, upgrade, proxy-authorization, and proxy-authenticate." A tracing sidecar that stamps x-amzn-trace-id after signing is enough, if your signer included it.
  4. 4 Where the hop signs for you, check its configuration instead of yours: it owns the service and the Region. AgentCore Gateway is the documented case: for an MCP server or OpenAPI target under IAM outbound authorization you supply an iamCredentialProvider whose service is "The AWS service name used for SigV4 signing. For example, bedrock-agentcore for MCP servers hosted on Amazon Bedrock AgentCore", and whose region is optional — "If you don't specify a Region, the gateway uses its own Region." A target in a different Region from the gateway therefore gets signed for the wrong one by default. Lambda, API Gateway, and Smithy targets take no iamCredentialProvider at all.
  5. 5 If the hop can't sign and can't be taken out of the path, pick a different method for that route. A PUT or POST through CloudFront's origin access control needs the caller to supply the hash: "your users must compute the SHA256 of the body and include the payload hash value of the request body in the x-amz-content-sha256 header when sending the request to CloudFront. Lambda doesn't support unsigned payloads." Every MCP message is a POST with a body, so that requirement applies to all of them.
Terminalbash
# Bisect the hops. Same body, same signer, one URL at a time.
#  1. the AWS hostname directly   -> https://url-id-12345.lambda-url.us-west-2.on.aws/mcp
#  2. through your load balancer  -> https://mcp-internal.example.com/mcp
#  3. through your CDN or domain  -> https://mcp.example.com/mcp
#
# Then read what actually arrived. Every signed name must survive the trip
# with the same value, and `host` must be the host that verifies.
curl -sS -D - -o /dev/null -X POST "$URL" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# The first URL that fails is the hop that changed the request.
CloudFront: restrict access to a Lambda function URL origin

Something else

Whatever the components are, this error has one shape: two canonical requests differ by one field, and only three things can be changed — what you sign, what arrives, or the scope you sign it under. Work it in that order. Read the response body first, because AWS names the service, the Region, or the date when one of those is what differed, and a bare mismatch is the only case that needs investigating. Then reproduce with a known-good signer over the same body, since every MCP message is a POST and a signer handed no body signs the empty-string hash. Then bisect the hops: sign for the AWS hostname, then for each proxy, ingress, or domain in front of it, and fix the layer where the request stopped matching rather than the signer. Two dead ends: this is not a permissions problem, though an IAM denial returns the same 403 and only the body distinguishes them, and it is not something the MCP specification can help with, because request signing sits outside the OAuth profile there.

Why it happens

Both ends compute the same thing. You build a canonical request — method, URI, query string, the canonical headers, the names in SignedHeaders, and a hex SHA-256 of the payload — hash it, prepend a credential scope that pins the date, the Region, and the service code, and sign that. AWS repeats the calculation on the bytes that arrived: "When the AWS service receives a signed request, it recalculates the signature. If there are differences in the values, then the signatures don't match."

So the message is not about invalid credentials. It is two canonical requests one field apart, and that field sits in one of four places.

The scope names the wrong service code or Region, which AWS reports with a message of its own rather than a bare mismatch. The payload hash differs, which is the MCP-specific case: every MCP request carries a body, and a signer handed the URL and headers but not the JSON-RPC body signs e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855, the empty-string hash, while AWS hashes the message.

A hop in the path rewrote a signed header — AWS's troubleshooting note: "You can also verify that you didn't send the request through a proxy that modifies the headers or the request." Or the key is wrong, and the signature came from a derived key or a secret that doesn't match the access key ID.

This is not an authorization failure: a signature that verifies and an IAM policy that denies both return 403, and only the body tells them apart. SigV4 also sits outside the MCP authorization specification — authorization there is OPTIONAL, and "Implementations using an HTTP-based transport SHOULD conform to this specification" — so no MCP mechanism helps you here, and AgentCore says as much in its own protocol contract: "SigV4-configured agents return HTTP 403 with an ACCESS_DENIED error and do not include WWW-Authenticate headers."

The exchange

  • Your agent IAM role credentials
  • AWS function URL, API Gateway, or AgentCore
  • MCP server never invoked
  1. No token is fetched and nothing is cached. The signature covers the host header, every name listed in SignedHeaders, and a SHA-256 of the JSON-RPC body, and the credential scope pins the date, the Region, and the service code.

    Inside Your agent

    Sign the canonical request scope: YYYYMMDD/region/service/aws4_request
  2. Your agent to AWS

    POST /mcp (carries the credential) Authorization: AWS4-HMAC-SHA256 …
  3. AWS replicates the whole calculation on the bytes it received. Because the check runs before the integration is invoked, the MCP server never executes and nothing in its logs will show the request.

    Inside AWS

    Recompute over what arrived host + SignedHeaders + payload hash
  4. AWS to Your agent

    403 SignatureDoesNotMatch (this step fails) two canonical requests, one field apart

    Flow stops here — MCP server not reached

The agent signs a canonical request that includes a SHA-256 of the JSON-RPC body and sends it to the AWS service in front of the MCP server. That service recomputes the signature over the request as it arrived, gets a different value, and returns 403 SignatureDoesNotMatch. The MCP server is never reached, so no handler, tool, or IAM policy is involved in the failure.
Zuplo

A verified signature tells you which IAM principal sent the request, and nothing about what that principal can do or about callers that have no principal at all.

The signature verified. Which of our tools can that role call?

mcp-capability-filter-inbound

Only what the route allowlists — a verified signature got the caller in, nothing more. The rest is blocked with MethodNotFound before the request reaches your server, and accessControl.mode: rolesAndGroups narrows the list per caller — though an IAM role is one caller for every workload that assumes it.

A partner's agent has no AWS account and cannot sign a thing.

mcp-oauth-inbound

They don't have to: the same route can authenticate a browser login against an OIDC provider you configure, because the inbound method is a policy rather than server code. Exactly one such policy per project — the runtime rejects a second.

A bad signature and an IAM denial are both 403. Which was it?

mcp_request_rejected

The event says which: every rejected request emits one carrying the reason, so a refusal resolves to a named outcome instead of a status line you have to infer a cause from.

Each API behind this server needs its own credential. Do the agents hold them all?

mcp-token-exchange-inbound

No. The specification forbids passthrough with a MUST NOT. The inbound credential is dropped and an independent upstream one is minted per user or per gateway.

All of these attach to one MCP route's policies.inbound — the same policy engine on the way in and on the way out. Your MCP server keeps the code and the authentication it has today.

Stop debugging auth on every MCP server

A gateway in front of your MCP servers handles discovery, audience binding, and token validation once — for every server and every client.