Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Aws Php Sns Message Validator Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install it and check two prerequisites before anything else:

composer require aws/aws-php-sns-message-validator:^1.10
  • PHP 8.1+ is required as of 1.10.0 (1.9.x still supports 7.2.5+). If you're pinned to an older runtime, constrain to ^1.9.
  • ext-openssl must be installed — this is the whole point of the package.
  • The library is standalone: it does not pull in 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.

Implementation Patterns

Middleware that validates once and hands off

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.

Cert caching via setCertClient

Out 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.

Acknowledge fast, process asynchronously

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.

Testing without hitting AWS

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin