Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,30 @@ Framework-specific (quick starts):

See the full indexed list at [`examples/README.md`](examples/README.md).

### Verifying webhook signatures

Mailtrap signs every outbound webhook with HMAC-SHA256 and sends the lowercase hex digest in the `Mailtrap-Signature` header. Verify the signature against the raw request body using the `signing_secret` returned when you created the webhook:

```php
use Mailtrap\Helper\WebhookSignature;

// $payload must be the unparsed request body bytes — do NOT re-serialize
// the parsed JSON, as that may reorder keys and invalidate the signature.
$rawBody = file_get_contents('php://input');
$valid = WebhookSignature::verify(
$rawBody !== false ? $rawBody : '',
$_SERVER['HTTP_MAILTRAP_SIGNATURE'] ?? '',
$_ENV['MAILTRAP_WEBHOOK_SIGNING_SECRET'] ?? ''
);

if (!$valid) {
http_response_code(401);
exit;
}
```

The helper performs a constant-time comparison and returns `false` (rather than raising) for empty, missing, or malformed signatures.

## Contributing

Bug reports and pull requests are welcome on [GitHub](https://github.com/railsware/mailtrap-php). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).
Expand Down
17 changes: 17 additions & 0 deletions examples/webhooks/verify_signature.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

use Mailtrap\Helper\WebhookSignature;

require __DIR__ . '/../../vendor/autoload.php';

// --- Direct verification (e.g. for unit tests or custom routers) ----------
$payload = '{"event":"delivery","message_id":"abc-123"}';
$signingSecret = '8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e';
$signature = hash_hmac('sha256', $payload, $signingSecret);

if (!WebhookSignature::verify($payload, $signature, $signingSecret)) {
fwrite(STDERR, "Signature verification failed!\n");
exit(1);
}
61 changes: 61 additions & 0 deletions src/Helper/WebhookSignature.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace Mailtrap\Helper;

/**
* Helpers for working with inbound Mailtrap webhooks.
*
* @see https://docs.mailtrap.io/email-api-smtp/advanced/webhooks#verifying-the-signature
*/
final class WebhookSignature
{
/**
* Hex-encoded HMAC-SHA256 signature length (SHA-256 produces 32 bytes / 64 hex chars).
*/
public const SIGNATURE_HEX_LENGTH = 64;

/**
* Verifies the HMAC-SHA256 signature of a Mailtrap webhook payload.
*
* Mailtrap signs every outbound webhook by computing
* `HMAC-SHA256(signing_secret, raw_request_body)` and sending the lowercase
* hex digest in the `Mailtrap-Signature` HTTP header. Compute the same
* digest on your side and compare it in constant time.
*
* The comparison is performed with {@see hash_equals()} to avoid timing
* side-channels.
*
* The method never raises on inputs that could plausibly arrive over the
* wire (empty strings, wrong-length signatures, non-hex characters, missing
* secret) — it simply returns `false`. This makes it safe to call directly
* from a request handler without wrapping in try/catch.
*
* @param string $payload The raw request body, exactly as received.
* **Do not** parse and re-serialize the JSON —
* re-encoding may reorder keys or alter
* whitespace and invalidate the signature.
* @param string $signature The value of the `Mailtrap-Signature` HTTP
* header (lowercase hex string).
* @param string $signingSecret The webhook's `signing_secret`, returned by
* the Webhooks API on webhook creation.
*
* @return bool `true` if the signature is valid for the given payload and
* secret, `false` otherwise.
*/
public static function verify(string $payload, string $signature, string $signingSecret): bool
{
if ($signature === '' || $signingSecret === '' || $payload === '') {
return false;
}

if (strlen($signature) !== self::SIGNATURE_HEX_LENGTH) {
return false;
}

$expected = hash_hmac('sha256', $payload, $signingSecret);

return hash_equals($expected, $signature);
}
}
150 changes: 150 additions & 0 deletions tests/Helper/WebhookSignatureTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

declare(strict_types=1);

namespace Mailtrap\Tests\Helper;

use Mailtrap\Helper\WebhookSignature;
use PHPUnit\Framework\TestCase;

/**
* @covers \Mailtrap\Helper\WebhookSignature
*
* Class WebhookSignatureTest
*/
class WebhookSignatureTest extends TestCase
{
// -----------------------------------------------------------------------
// Cross-SDK fixture
//
// The (payload, signing_secret, expected_signature) triple below is the
// canonical fixture shared verbatim by every official Mailtrap SDK
// (mailtrap-ruby, mailtrap-python, mailtrap-php, mailtrap-nodejs,
// mailtrap-java, mailtrap-dotnet). Any change here MUST be mirrored in
// the equivalent test files in the other SDKs so the helpers stay
// byte-for-byte compatible across languages.
// -----------------------------------------------------------------------
private const FIXTURE_PAYLOAD = '{"event":"delivery","sending_stream":"transactional","category":"welcome","message_id":"a8b1d8f6-1f8d-4a3c-9b2e-1a2b3c4d5e6f","email":"recipient@example.com","event_id":"f1e2d3c4-b5a6-7890-1234-567890abcdef","timestamp":1716070000}';
private const FIXTURE_SIGNING_SECRET = '8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e';
private const FIXTURE_EXPECTED_SIGNATURE = '6d262e2611cd09be1f948382b5c611d63b0e585c4c9c5e40139d6ac3876d5433';

// --- 1. Valid signature for given payload + secret ---------------------
public function testReturnsTrueForValidSignaturePayloadAndSecret(): void
{
$this->assertTrue(
WebhookSignature::verify(
self::FIXTURE_PAYLOAD,
self::FIXTURE_EXPECTED_SIGNATURE,
self::FIXTURE_SIGNING_SECRET
)
);
}

// --- 2. Wrong secret ---------------------------------------------------
public function testReturnsFalseWithWrongSigningSecret(): void
{
$this->assertFalse(
WebhookSignature::verify(
self::FIXTURE_PAYLOAD,
self::FIXTURE_EXPECTED_SIGNATURE,
'ffffffffffffffffffffffffffffffff'
)
);
}

// --- 3. Payload tampered (one byte changed) ----------------------------
public function testReturnsFalseWhenPayloadIsTampered(): void
{
$tampered = str_replace('delivery', 'Delivery', self::FIXTURE_PAYLOAD);

$this->assertFalse(
WebhookSignature::verify(
$tampered,
self::FIXTURE_EXPECTED_SIGNATURE,
self::FIXTURE_SIGNING_SECRET
)
);
}

// --- 4. Signature with wrong length ------------------------------------
public function testReturnsFalseWithoutRaisingWhenSignatureTooShort(): void
{
$tooShort = substr(self::FIXTURE_EXPECTED_SIGNATURE, 0, 31);

$this->assertFalse(
WebhookSignature::verify(
self::FIXTURE_PAYLOAD,
$tooShort,
self::FIXTURE_SIGNING_SECRET
)
);
}

// --- 5. Signature with non-hex characters ------------------------------
public function testReturnsFalseWithoutRaisingForNonHexSignature(): void
{
$notHex = str_repeat('z', WebhookSignature::SIGNATURE_HEX_LENGTH);

$this->assertFalse(
WebhookSignature::verify(
self::FIXTURE_PAYLOAD,
$notHex,
self::FIXTURE_SIGNING_SECRET
)
);
}

// --- 6. Empty signature string -----------------------------------------
public function testReturnsFalseForEmptySignature(): void
{
$this->assertFalse(
WebhookSignature::verify(
self::FIXTURE_PAYLOAD,
'',
self::FIXTURE_SIGNING_SECRET
)
);
}

// --- 7. Empty signing_secret -------------------------------------------
public function testReturnsFalseForEmptySigningSecret(): void
{
$this->assertFalse(
WebhookSignature::verify(
self::FIXTURE_PAYLOAD,
self::FIXTURE_EXPECTED_SIGNATURE,
''
)
);
}

// --- 8. Empty payload + non-empty signature ----------------------------
public function testReturnsFalseForEmptyPayload(): void
{
$this->assertFalse(
WebhookSignature::verify(
'',
self::FIXTURE_EXPECTED_SIGNATURE,
self::FIXTURE_SIGNING_SECRET
)
);
}

// --- 9. Known-good cross-SDK fixture -----------------------------------
public function testMatchesHardcodedHmacSha256DigestForSharedFixture(): void
{
// Recompute the digest in-place so a regression in PHP's hash
// extension or the fixture itself fails loudly: this is the
// byte-for-byte contract every other Mailtrap SDK must satisfy.
$computed = hash_hmac('sha256', self::FIXTURE_PAYLOAD, self::FIXTURE_SIGNING_SECRET);

$this->assertSame(self::FIXTURE_EXPECTED_SIGNATURE, $computed);
$this->assertTrue(
WebhookSignature::verify(
self::FIXTURE_PAYLOAD,
self::FIXTURE_EXPECTED_SIGNATURE,
self::FIXTURE_SIGNING_SECRET
)
);
}
}
Loading