Skip to content

Inconsistent header ordering in presigned URLs with custom headers causes SignatureDoesNotMatch errors (bis) #3241

@DisasteR

Description

@DisasteR

Describe the bug

Happy New Year to the AWS SDK team! 🎉

@stobrien89 - I'm following up on the original issue #3109 that you handled. I apologize for the delayed response - when PR #3201 was mentioned as potentially resolving the problem, I wanted to thoroughly test it before responding.

I've now tested the latest SDK version (3.369.9) and can confirm that the original issue persists.

The Problem:
The AWS SDK for PHP v3.369.9 contains an inconsistency in how headers are sorted during the signature v4 process for presigned URLs. Headers are sorted alphabetically in createContext() when calculating the signature, but are not sorted in getPresignHeaders() when building the X-Amz-SignedHeaders query parameter. This causes signature verification failures with S3-compatible services like Dell ECS.

Why PR #3201 doesn't fix this:
PR #3201 (merged in v3.369.5) fixed sorting of query parameters in getCanonicalizedQuery(), but our issue concerns headers in getPresignHeaders() - these are two different methods that were not both addressed.

Regression Issue

  • Select this option if this issue appears to be a regression.

Expected Behavior

When generating presigned URLs with custom headers, the X-Amz-SignedHeaders query parameter should contain headers sorted alphabetically, matching the order used when calculating the signature in createContext().

Example:

X-Amz-SignedHeaders=a-custom-header;host;z-custom-header

(Headers sorted alphabetically)

This ensures consistency between:

  1. The order used to calculate the signature (in createContext())
  2. The order declared in X-Amz-SignedHeaders (used by S3 services for verification)

Current Behavior

Headers in X-Amz-SignedHeaders are returned in insertion order (not sorted), causing a mismatch with the alphabetically-sorted order used during signature calculation.

Example:

X-Amz-SignedHeaders=host;z-custom-header;a-custom-header

(Headers in insertion order, NOT alphabetically sorted)

Error from Dell ECS:

HTTP/1.1 403 Forbidden
<Error>
  <Code>SignatureDoesNotMatch</Code>
  <Message>The request signature we calculated does not match the signature you provided.</Message>
</Error>

Dell ECS canonical request shows the inconsistency:

<CanonicalRequest>PUT
/testfile.txt
[...query parameters...]
a-my-test-header:valeur-de-test
host:test-bsz.s3.fr1.rec.lan:9020

host;a-my-test-header
UNSIGNED-PAYLOAD</CanonicalRequest>

Notice:

  • Canonical headers section: alphabetically sorted (a-my-test-header, then host)
  • SignedHeaders line: NOT alphabetically sorted (host;a-my-test-header)

Reproduction Steps

1. Create test file (test.php):

<?php
require 'vendor/autoload.php';

use Aws\S3\S3Client;
use Aws\Middleware;
use Psr\Http\Message\RequestInterface;

$s3Client = new S3Client([
    'version' => 'latest',
    'endpoint' => 'http://your-s3-compatible-endpoint:9020',
    'region' => 'us-east-1',
    'credentials' => [
        'key' => 'YOUR_ACCESS_KEY',
        'secret' => 'YOUR_SECRET_KEY',
    ],
    'signature_version' => 'v4',
]);

$cmd = $s3Client->getCommand('PutObject', [
    'Bucket' => 'test-bucket',
    'Key' => 'test-file.txt',
    'ContentLength' => 100,
]);

// Add custom headers in non-alphabetical order
$cmd->getHandlerList()->appendBuild(
    Middleware::mapRequest(function (RequestInterface $request) {
        return $request
            ->withHeader('z-custom-header', 'value-z')
            ->withHeader('a-custom-header', 'value-a');
    }),
    'add-custom-headers'
);

$request = $s3Client->createPresignedRequest($cmd, '+20 minutes');
$signedUrl = (string) $request->getUri();

// Extract X-Amz-SignedHeaders
if (preg_match('/X-Amz-SignedHeaders=([^&]+)/', $signedUrl, $matches)) {
    $signedHeaders = urldecode($matches[1]);
    echo "Headers order: " . $signedHeaders . "\n";
    // Expected: a-custom-header;host;z-custom-header (alphabetical)
    // Actual: host;z-custom-header;a-custom-header (insertion order)
}

// Try to use the URL
$curlCmd = "curl -X PUT '$signedUrl' -H 'Host: your-endpoint' -H 'z-custom-header: value-z' -H 'a-custom-header: value-a' --data-binary '@test-file.txt'";
echo "Execute: $curlCmd\n";

2. Run the test:

php test.php

3. Observe:

  • Headers in X-Amz-SignedHeaders are NOT alphabetically sorted
  • Request fails with 403 SignatureDoesNotMatch on S3-compatible services that strictly validate signature v4

Possible Solution

Add sorting in the getPresignHeaders() method, similar to what was done in PR #3201 for query parameters.

Current code in src/Signature/SignatureV4.php (line ~285):

private function getPresignHeaders(array $headers)
{
    $presignHeaders = [];
    $blacklist = $this->getHeaderBlacklist();
    foreach ($headers as $name => $value) {
        $lName = strtolower($name);
        if (!isset($blacklist[$lName])
            && $name !== self::AMZ_CONTENT_SHA256_HEADER
        ) {
            $presignHeaders[] = $lName;  // ❌ No sorting!
        }
    }
    return $presignHeaders;  // ❌ Returned unsorted!
}

Proposed fix:

private function getPresignHeaders(array $headers)
{
    $presignHeaders = [];
    $blacklist = $this->getHeaderBlacklist();
    foreach ($headers as $name => $value) {
        $lName = strtolower($name);
        if (!isset($blacklist[$lName])
            && $name !== self::AMZ_CONTENT_SHA256_HEADER
        ) {
            $presignHeaders[] = $lName;
        }
    }
    sort($presignHeaders);  // ✅ Sort alphabetically!
    return $presignHeaders;
}

This single line addition ensures consistency with how headers are sorted in createContext() when calculating the signature.

Additional Information/Context

Related Issues and PRs

Why other AWS SDKs don't have this issue

We compared the PHP SDK with other official AWS SDKs - all consistently sort headers:

  • AWS SDK for Go: Uses sort.Strings() to sort headers
  • AWS SDK for Java: Uses Collections.sort() with case-insensitive comparison
  • AWS SDK for .NET: Uses SortedDictionary with ordinal comparison
  • AWS SDK for Python (boto3): Uses sorted() to sort headers

Impact

This issue affects:

  • Any S3-compatible service that strictly validates signature v4 (like Dell ECS, MinIO, Ceph)
  • Applications using presigned URLs with multiple custom headers
  • Users migrating from other AWS SDKs to PHP

Workarounds

Three workarounds exist (documented in #3109):

  1. Custom implementation without the SDK
  2. Extending SignatureV4 class to override getPresignHeaders()
  3. Using middleware to re-sort headers

However, a fix in the SDK would be preferable.

Test Results

Tested comprehensively on January 9, 2026 with:

  • SDK v3.369.9 (latest version)
  • Dell ECS v3.8.1.6
  • Real production environment
  • Result: ❌ 403 SignatureDoesNotMatch confirmed

References

SDK version used

3.369.9

Environment details (Version of PHP (php -v)? OS name and version, etc.)

PHP 8.4.0 (cli) - Linux (Docker container: php:8.4-cli)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugThis issue is a bug.queuedThis issues is on the AWS team's backlog

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions