aws/aws-php-sns-message-validator
Standalone PHP library for validating incoming Amazon SNS HTTP(S) POST notifications. It checks the SigningCertURL, SignatureVersion, and Signature; requires PHP 8.1+ and OpenSSL, with no AWS SDK or Guzzle dependency.
Install it and check two prerequisites before anything else:
composer require aws/aws-php-sns-message-validator:^1.10
^1.9.ext-openssl must be installed — this is the whole point of the package.aws/aws-sdk-php or Guzzle. That's a genuine win if SNS is the only AWS surface your app touches.The public API is small and you should learn it exactly — some blog posts and AI-generated snippets get it wrong:
| Class | Method | Purpose |
|---|---|---|
Aws\Sns\Message |
fromRawPostData() |
Reads php://input, decodes JSON |
fromJsonString(string $json) |
Public since 1.6.0 — prefer this in Laravel | |
fromPsr7Request($request) |
Since 1.2.0, needs a PSR-7 request | |
Aws\Sns\MessageValidator |
isValid(Message): bool |
Returns false on failure |
validate(Message): void |
Throws InvalidSnsMessageException |
|
setHostPattern(string) / setCertClient(callable) |
Testing + caching hooks |
Your first use case should be the subscription handshake, not notifications — it's the one that silently breaks integrations:
use Aws\Sns\Message;
use Aws\Sns\MessageValidator;
use Aws\Sns\Exception\InvalidSnsMessageException;
$message = Message::fromJsonString(request()->getContent());
$validator = new MessageValidator();
try {
$validator->validate($message);
} catch (InvalidSnsMessageException $e) {
report($e);
abort(403);
}
if ($message['Type'] === 'SubscriptionConfirmation') {
Http::get($message['SubscribeURL'])->throw(); // GET, never POST
return response('OK', 200);
}
Register the route outside the web middleware group (or except it from VerifyCsrfToken), because SNS sends no CSRF token and no session cookie.
The idiomatic Laravel shape is a route middleware that validates, enforces the topic, and stashes the payload on the request so controllers never re-validate (each validate() call can hit the network for a certificate).
class ValidateSnsMessage
{
public function __construct(private MessageValidator $validator) {}
public function handle(Request $request, Closure $next)
{
try {
$message = Message::fromJsonString($request->getContent());
$this->validator->validate($message);
} catch (\InvalidArgumentException | InvalidSnsMessageException $e) {
Log::warning('Rejected SNS message', ['error' => $e->getMessage()]);
abort(403);
}
// The library does NOT check that the topic is yours.
abort_unless(
hash_equals((string) config('services.sns.topic_arn'), (string) ($message['TopicArn'] ?? '')),
403
);
$request->attributes->set('sns', $message);
return $next($request);
}
}
Bind MessageValidator as a singleton in a service provider so you can inject your cert-caching client in one place.
setCertClientOut of the box the validator fetches the signing certificate over HTTPS on every message using file_get_contents. For a webhook that's an outbound round-trip per request. Wrap it:
$this->app->singleton(MessageValidator::class, function () {
$validator = new MessageValidator();
$validator->setCertClient(function (string $url): string {
return Cache::remember('sns:cert:' . sha1($url), now()->addHours(12), function () use ($url) {
$pem = @file_get_contents($url);
if ($pem === false) {
throw new \RuntimeException("Unable to fetch SNS cert: {$url}");
}
return $pem;
});
});
return $validator;
});
The validator checks SigningCertURL against /^sns\.[a-zA-Z0-9\-]{3,}\.amazonaws\.com(\.cn)?$/ before invoking your client, so the URL you cache is already constrained — but never loosen that pattern in production to make tests pass.
SNS retries on non-2xx and on timeouts, and retries are deliveries of the same MessageId. Validate in the middleware, then dispatch a job and return 200 immediately:
if ($message['Type'] === 'Notification') {
ProcessSnsNotification::dispatch($message->toArray());
}
return response('OK', 200);
Deduplicate on MessageId (Cache::add("sns:{$message['MessageId']}", true, now()->addDay())) — see the gotchas.
Two seams, both intended for this:
$validator = new MessageValidator(fn () => $this->testCertPem);
$validator->setHostPattern('/^localhost$/');
Generate a throwaway keypair in your test (openssl_pkey_new), build the canonical string in the documented order — Message, MessageId, Subject, Timestamp, TopicArn, Type (omit absent fields) — and sign with OPENSSL_ALGO_SHA256 for SignatureVersion: 2. That gives you a full round-trip test of your middleware with zero network calls and no Http::fake() gymnastics.
Message is ArrayAccess, not an object with properties. $message['Message'] works; $message->Message is null. This bites in Blade and in data_get().
Don't use fromRawPostData() under Octane. It reads the php://input stream directly, which is empty/unreliable under Swoole, RoadRunner, and Lambda runtimes. Message::fromJsonString($request->getContent()) is equivalent, testable, and Octane-safe. It's also the only option when the body has already been consumed.
SNS posts Content-Type: text/plain; charset=UTF-8. Laravel will not parse it into the request, so $request->all(), $request->validate(), and FormRequest rules see an empty array. Always work from getContent().
The library does not check TopicArn, timestamps, or replay. A valid signature only proves an AWS SNS topic sent it — anyone with an AWS account can sign a message to their own topic and point it at your endpoint. Compare TopicArn against config (see middleware above). There's also no freshness window; don't invent a tight one because SNS retries can be delayed minutes-to-hours apart. Use MessageId dedupe instead:
if (! Cache::add("sns:{$message['MessageId']}", true, now()->addDay())) {
return response('OK'); // already handled
}
Don't mutate the message before validating. The signature covers the serialized field values; the validator re-derives the canonical string from the decoded payload. Decode → validate → then json_decode($message['Message']).
SubscribeURL is an SSRF vector if you loosen anything. It is part of the signed string, so a valid message's URL is trustworthy — but only after validate() succeeds. Never file_get_contents($message['SubscribeURL']) before validation, and prefer Http::get() (which won't follow arbitrary schemes) over file_get_contents on a user-supplied URL.
Handle UnsubscribeConfirmation explicitly. SNS sends it when a human unsubscribes via the console; if your webhook returns 500 it retries. Either accept and log it, or re-subscribe via SubscribeURL — but decide deliberately.
SNS → SQS is a different problem. This library validates HTTP(S) deliveries only. If you subscribe a queue to SNS and consume via Laravel's queue, you're relying on IAM/SQS, not signatures; validating SQS-wrapped SNS payloads needs a separate signature scheme. Also, if you enable "Raw Message Delivery," the body is no longer an SNS envelope at all.
Prefer isValid() when you want a boolean, validate() when you want a reason. isValid() swallows why it failed. validate() throws InvalidSnsMessageException whose message distinguishes bad host from missing cert from bad signature (granularity added in 1.5.0) — log that message, it's the fastest debugging lever you have.
Debugging checklist when everything 403s: (1) is SigningCertURL host matching the pattern — a private VPC endpoint or amazonaws.com.cn region will surprise you; (2) is the outbound HTTPS call to the cert URL blocked by a firewall or proxy, which masquerades as a signature failure; (3) is your TopicArn config stale after a topic recreation; (4) is a global Http/Guzzle proxy configured that file_get_contents ignores.
Treat it as feature-stable, not actively developed. The changelog is bumps and PHP-version alignment (8.1 support, ReturnTypeWillChange, nullable $certClient). Pin ^1.10, read the changelog before minor bumps, and keep the ~400 lines of validation logic behind your own middleware so swapping it out is a one-file change. Apache-2.0 and low-dependency make that cheap.
How can I help you explore Laravel packages today?