CI Hex.pm Hex Docs Downloads License

Verify AWS SNS HTTPS message authenticity โ€” RSA-SHA256 signature verification for Elixir applications.

ExAwsSnsVerifier validates Notification, SubscriptionConfirmation, and UnsubscribeConfirmation payloads sent via the AWS SNS HTTPS transport. It is the Elixir equivalent of Ruby's Aws::SNS::MessageVerifier.

No runtime dependencies โ€” uses :public_key for RSA verification and :httpc for certificate fetching.

Features

  • ๐Ÿ” RSA-SHA256 verification โ€” validates SignatureVersion 2 signatures using the AWS signing certificate
  • ๐Ÿ“ฌ All message types โ€” Notification, SubscriptionConfirmation, and UnsubscribeConfirmation
  • ๐Ÿ›ก๏ธ Topic ARN allowlist โ€” restrict which topics are accepted
  • ๐Ÿ”’ URL hardening โ€” enforces HTTPS, host whitelist (sns.<region>.amazonaws.com), no credentials in URL, .pem extension for certs
  • โฐ Replay protection โ€” configurable timestamp window (default: 1 hour)
  • ๐Ÿ—„๏ธ Cert caching โ€” :persistent_term-backed cache with 24-hour TTL
  • ๐Ÿ”Œ Pluggable HTTP client โ€” swap in Tesla, Req, Finch, or any custom client
  • ๐Ÿงฉ Plug integration โ€” ExAwsSnsVerifier.Plug for Phoenix / Plug pipelines
  • โšก Consistent error handling โ€” verify/2 returns {:ok, payload} or {:error, reason}; verify!/2 raises
  • ๐Ÿ“ฆ Zero runtime dependencies โ€” no Jason, no HTTPoison, no extra baggage
  • ๐Ÿงช Fully tested โ€” matrix across OTP 26โ€“28 and Elixir 1.16โ€“1.19

Installation

Add ex_aws_sns_verifier to your mix.exs:

def deps do
  [
    {:ex_aws_sns_verifier, "~> 0.2.0"}
  ]
end

Then run:

mix deps.get

Quick Start

1. One-shot verification

Pass the raw JSON body and a set of allowed topic ARNs:

raw_body = ~s({
  "Type": "Notification",
  "MessageId": "22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324",
  "TopicArn": "arn:aws:sns:us-east-1:123456789012:MyTopic",
  "Message": "Hello from SNS!",
  "Timestamp": "2026-05-13T12:00:00.000Z",
  "SignatureVersion": "2",
  "Signature": "...",
  "SigningCertURL": "https://sns.us-east-1.amazonaws.com/...pem",
  ...
})

opts = [
  allowed_topic_arns: ["arn:aws:sns:us-east-1:123456789012:MyTopic"]
]

{:ok, payload} = ExAwsSnsVerifier.verify(raw_body, opts)

2. With a Verifier struct (reusable config)

verifier = ExAwsSnsVerifier.new(
  allowed_topic_arns: ["arn:aws:sns:us-east-1:123456789012:MyTopic"],
  timestamp_window_seconds: 300  # 5 minutes
)

{:ok, payload} = ExAwsSnsVerifier.verify(verifier, raw_body)

3. Raise on failure

payload = ExAwsSnsVerifier.verify!(raw_body, opts)
# Raises ExAwsSnsVerifier.VerificationError on failure

4. With a Plug pipeline

# In your router or endpoint
plug ExAwsSnsVerifier.Plug,
     allowed_topic_arns: ["arn:aws:sns:us-east-1:123456789012:MyTopic"]

The plug reads the raw body, verifies the SNS signature, and assigns {:ok, payload} or {:error, reason} to conn.assigns.sns_verification. On failure, the connection is halted with 403.

Configuration

Verifier options

OptionDefaultDescription
allowed_topic_arns(required)List of allowed TopicArn values
allowed_regionsAll commercial regionsList of AWS regions for SigningCertURL validation
timestamp_window_seconds3600Replay protection window in seconds
http_clientExAwsSnsVerifier.Cert.HttpClientModule implementing get/1 for cert fetching
cert_cacheExAwsSnsVerifier.Cert.CacheModule implementing get/1 and put/2

Custom HTTP client

Swap in your own HTTP client (Tesla, Req, Finch, etc.):

defmodule MyApp.MyHttpClient do
  @behaviour ExAwsSnsVerifier.Cert.HttpClientBehaviour

  @impl true
  def get(url) do
    # Return {:ok, body} or {:error, reason}
    Req.get!(url).body
  end
end

verifier = ExAwsSnsVerifier.new(
  allowed_topic_arns: ["..."],
  http_client: MyApp.MyHttpClient
)

Custom cert cache

defmodule MyApp.MyCache do
  @behaviour ExAwsSnsVerifier.Cert.CacheBehaviour  # get/1, put/2

  @impl true
  def get(key), do: # ...
  @impl true
  def put(key, value), do: # ...
end

Error Reasons

verify/2 returns {:error, reason} with one of the following atoms:

ErrorMeaning
:invalid_jsonBody is not valid JSON
:unknown_message_typeType is not Notification/SubscriptionConfirmation/UnsubscribeConfirmation
:missing_signature_versionNo SignatureVersion field
:unsupported_signature_versionOnly Version 2 (SHA256) supported
:missing_timestampNo Timestamp field
:invalid_timestampTimestamp is not valid ISO 8601
:timestamp_out_of_windowMessage is outside the replay window
:missing_topic_arnNo TopicArn field
:no_allowed_topicsAllowlist is empty
:topic_not_allowedTopicArn not in allowlist
:missing_signatureNo Signature field
:invalid_signature_encodingSignature is not valid Base64
:invalid_cert_urlSigningCertURL failed host/path validation
:missing_signing_cert_urlNo SigningCertURL field
:signature_invalidRSA-SHA256 signature does not verify

How it works

  1. Parse โ€” decodes the JSON body
  2. Validate type โ€” confirms one of the three supported message types
  3. Validate signature version โ€” only SignatureVersion 2 (RSA-SHA256)
  4. Validate timestamp โ€” checks the message is within the replay window
  5. Validate topic โ€” confirms the TopicArn is in the allowlist
  6. Build canonical string โ€” constructs the signed payload per AWS spec
  7. Fetch cert โ€” downloads and caches the signing certificate from SigningCertURL
  8. Verify signature โ€” RSA-SHA256 verification using :public_key

Development

git clone https://github.com/GustavoZiaugra/ex_aws_sns_verifier.git
cd ex_aws_sns_verifier
mix deps.get
mix compile --warnings-as-errors

# Run tests
mix test

# Quality checks
mix format --check-formatted
mix credo --strict
mix dialyzer

# Generate docs
mix docs

License

MIT ยฉ Gustavo Ziaugra