Initializing Enclave...

Fixing S3 Presigned URL 'SignatureDoesNotMatch' After IAM User Policy Update

Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 5–15 mins


TL;DR

  • What broke: An IAM user policy update (permission change, inline policy swap, or MFA condition addition) invalidated the signing credentials embedded in your presigned URL, causing AWS to reject the signature as SignatureDoesNotMatch.
  • How to fix it: Regenerate the presigned URL using the current, active credentials for that IAM principal — ensuring region, endpoint, SigV4 parameters, and session tokens are all consistent with the updated policy context.
  • Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your broken URL generation code and get corrected boto3/CLI output without leaking your ARNs or keys.

The Incident (What Does the Error Mean?)

You hit this raw error from AWS S3:

<Error>
  <Code>SignatureDoesNotMatch</Code>
  <Message>The request signature we calculated does not match the signature you provided.
  Check your key and signing method.</Message>
  <AWSAccessKeyId>AKIAIOSFODNN7EXAMPLE</AWSAccessKeyId>
  <StringToSign>AWS4-HMAC-SHA256
20240115T142300Z
20240115/us-east-1/s3/aws4_request
abc123...</StringToSign>
  <SignatureProvided>abcdef1234567890...</SignatureProvided>
  <StringToSignBytes>41 57 53 34...</StringToSignBytes>
  <RequestId>EXAMPLE123456789</RequestId>
  <HostId>EXAMPLE+HOST+ID==</HostId>
</Error>

Immediate consequence: Every consumer of that presigned URL — your frontend upload widget, your CDN origin fetch, your partner API callback — gets an HTTP 403. The URL is cryptographically dead. It cannot be retried. It must be regenerated.

A presigned URL is a snapshot of credentials at generation time. It encodes X-Amz-Credential, X-Amz-Signature, and X-Amz-Security-Token (if STS-based) directly into the query string. The moment the IAM policy governing those credentials changes in a way that affects the signing context — or the credentials themselves are rotated/invalidated — AWS's signature verification fails at the SigV4 HMAC comparison step.


The Attack Vector / Blast Radius

This is not just a broken URL. Here is the full blast radius:

1. Silent production breakage at scale. If your application pre-generates presigned URLs and caches them (Redis, DynamoDB TTL, CDN edge cache), every cached URL is now a 403. Users see broken downloads, failed uploads, and cryptic permission errors with no actionable message.

2. The dangerous misdiagnosis trap. Engineers under pressure misread SignatureDoesNotMatch as a key rotation issue and immediately rotate the IAM access key — which generates new credentials but doesn't fix the root cause (the policy condition or STS token invalidation). Now you have rotated keys AND broken URLs.

3. STS session token invalidation cascade. If the presigned URL was generated using AssumeRole temporary credentials (STS), and the role's trust policy or permission boundary was updated, the session token embedded in X-Amz-Security-Token is invalidated server-side. No amount of URL regeneration with the old STS token fixes this. You must re-assume the role.

4. MFA condition injection. If an admin added aws:MultiFactorAuthPresent: true as a condition to the S3 bucket policy or IAM policy after URLs were generated, those pre-generated URLs — signed without an MFA session — will permanently fail, even if the signing key is valid.

5. Cross-region endpoint mismatch. A policy update that moves bucket access to a specific VPC endpoint or enforces aws:SourceVpc conditions will cause SigV4 region/endpoint mismatches if your URL was generated against s3.amazonaws.com instead of the regional endpoint s3.us-east-1.amazonaws.com.


How to Fix It (The Solution)

Diagnostic Checklist First

Before touching code, run this triage sequence:

# 1. Confirm the signing identity is still valid
aws sts get-caller-identity --profile <your-profile>

# 2. Check if the IAM user's access key is still active
aws iam list-access-keys --user-name <iam-username>

# 3. Verify no SCP or permission boundary blocks s3:GetObject
aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:user/your-user \
  --action-names s3:GetObject \
  --resource-arns arn:aws:s3:::your-bucket/your-key

# 4. Check bucket policy for new conditions (MFA, VPC, IP)
aws s3api get-bucket-policy --bucket your-bucket | python3 -m json.tool

Basic Fix — Regenerate With Current Credentials

import boto3
from botocore.config import Config

- # BAD: Using hardcoded/cached credentials that may be stale post-policy-update
- session = boto3.Session(
-     aws_access_key_id="AKIAIOSFODNN7EXAMPLE",
-     aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
- )
- s3_client = session.client('s3')
- url = s3_client.generate_presigned_url(
-     'get_object',
-     Params={'Bucket': 'my-bucket', 'Key': 'my-object'},
-     ExpiresIn=3600
- )

+ # GOOD: Use environment/instance credentials — always reflect current IAM state
+ session = boto3.Session()  # Resolves credentials from env, instance profile, or ~/.aws
+ s3_client = session.client(
+     's3',
+     region_name='us-east-1',           # MUST match bucket region explicitly
+     config=Config(signature_version='s3v4')  # Force SigV4; SigV2 is deprecated and broken
+ )
+ url = s3_client.generate_presigned_url(
+     'get_object',
+     Params={'Bucket': 'my-bucket', 'Key': 'my-object'},
+     ExpiresIn=900  # 15 min max for STS-based credentials
+ )
+ print(url)

Enterprise Best Practice — STS AssumeRole With Explicit Re-assumption

If your presigned URLs are generated by a Lambda or service using AssumeRole, the STS session must be refreshed after any role policy update. Do not cache STS tokens across policy change events.

import boto3
from botocore.config import Config
import time

- # BAD: Caching STS credentials at module load time
- # If role policy changes, this session is permanently broken until Lambda cold-starts
- sts = boto3.client('sts')
- assumed = sts.assume_role(
-     RoleArn='arn:aws:iam::123456789012:role/S3PresignRole',
-     RoleSessionName='presign-session'
- )
- CACHED_CREDS = assumed['Credentials']  # Module-level cache — DANGEROUS
-
- def generate_url(bucket, key):
-     s3 = boto3.client(
-         's3',
-         aws_access_key_id=CACHED_CREDS['AccessKeyId'],
-         aws_secret_access_key=CACHED_CREDS['SecretAccessKey'],
-         aws_session_token=CACHED_CREDS['SessionToken']
-     )
-     return s3.generate_presigned_url('get_object',
-         Params={'Bucket': bucket, 'Key': key}, ExpiresIn=3600)

+ # GOOD: Re-assume role per invocation (or implement TTL-aware credential refresh)
+ def get_fresh_s3_client():
+     sts = boto3.client('sts', region_name='us-east-1')
+     assumed = sts.assume_role(
+         RoleArn='arn:aws:iam::123456789012:role/S3PresignRole',
+         RoleSessionName=f'presign-{int(time.time())}',
+         DurationSeconds=900  # Match URL expiry to session lifetime
+     )
+     creds = assumed['Credentials']
+     return boto3.client(
+         's3',
+         region_name='us-east-1',
+         aws_access_key_id=creds['AccessKeyId'],
+         aws_secret_access_key=creds['SecretAccessKey'],
+         aws_session_token=creds['SessionToken'],  # CRITICAL: must include session token
+         config=Config(signature_version='s3v4')
+     )
+
+ def generate_url(bucket, key):
+     s3 = get_fresh_s3_client()
+     return s3.generate_presigned_url(
+         'get_object',
+         Params={'Bucket': bucket, 'Key': key},
+         ExpiresIn=900
+     )

Key constraint: ExpiresIn for STS-backed presigned URLs cannot exceed the STS session duration. If your DurationSeconds=900 and you set ExpiresIn=3600, the URL expires at 900 seconds regardless — and AWS may reject it earlier with SignatureDoesNotMatch due to token expiry.


💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

1. Checkov — Catch Overpermissive Presign Policies Pre-Merge

# .checkov.yml
checks:
  - CKV_AWS_53   # Ensure S3 bucket has block public ACLs
  - CKV_AWS_54   # Ensure S3 bucket has block public policy
  - CKV_AWS_19   # Ensure S3 bucket has server-side encryption

Run in CI:

checkov -d ./terraform --framework terraform \
  --check CKV_AWS_53,CKV_AWS_54 \
  --soft-fail-on MEDIUM

2. OPA Policy — Enforce SigV4 and Regional Endpoints

# policy/s3_presign.rego
package aws.s3.presign

deny[msg] {
  input.signature_version != "s3v4"
  msg := "Presigned URLs must use SigV4. SigV2 is deprecated and insecure."
}

deny[msg] {
  not contains(input.endpoint_url, input.region)
  msg := sprintf("Endpoint '%v' does not match region '%v'. Use regional S3 endpoints.",
    [input.endpoint_url, input.region])
}

deny[msg] {
  input.expires_in > 3600
  msg := "Presigned URL expiry must not exceed 3600 seconds for STS-backed credentials."
}

3. AWS CloudTrail Alert — Detect Policy Changes That Break Presigned URLs

Deploy this EventBridge rule to alert when IAM policy mutations occur against roles/users that generate presigned URLs:

{
  "source": ["aws.iam"],
  "detail-type": ["AWS API Call via CloudTrail"],
  "detail": {
    "eventSource": ["iam.amazonaws.com"],
    "eventName": [
      "PutUserPolicy",
      "AttachUserPolicy",
      "DetachUserPolicy",
      "PutRolePolicy",
      "AttachRolePolicy",
      "DetachRolePolicy",
      "PutRolePermissionsBoundary"
    ],
    "requestParameters": {
      "userName": ["s3-presign-service-user"]
    }
  }
}

Route this to an SNS topic that triggers a Lambda to invalidate your presigned URL cache (Redis DEL, DynamoDB TTL reset) immediately on policy change.

4. Terraform — Lock Presign IAM User to Least-Privilege Inline Policy

- # BAD: Managed policy allows broad S3 access — any policy update breaks presigned URLs unpredictably
- resource "aws_iam_user_policy_attachment" "presign_user" {
-   user       = aws_iam_user.presign.name
-   policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
- }

+ # GOOD: Inline policy scoped to exact bucket and operation
+ resource "aws_iam_user_policy" "presign_user_inline" {
+   name = "s3-presign-least-privilege"
+   user = aws_iam_user.presign.name
+
+   policy = jsonencode({
+     Version = "2012-10-17"
+     Statement = [{
+       Sid      = "PresignGetOnly"
+       Effect   = "Allow"
+       Action   = ["s3:GetObject"]
+       Resource = "arn:aws:s3:::${var.bucket_name}/${var.allowed_prefix}/*"
+       Condition = {
+         StringEquals = {
+           "aws:RequestedRegion" = var.aws_region
+         }
+       }
+     }]
+   })
+ }

Narrow scope = fewer policy changes that can break signature context.

Related Diagnostics

"Part of the Security Utility Matrix."

View all 140 Security Tools →