← Back to blog

How to verify App Store Server Notifications V2 JWS signatures in Ruby

A production-tested walk-through of verifying Apple's ES256-signed webhooks in Ruby — the x5c chain, Apple Root CA G3, the marker OIDs almost every tutorial misses, freshness windows sized to Apple's retry schedule, and the three nested JWS layers you actually have to decode.

· 11 min read

Ruby App Store Server Notifications Security iOS

If you’re building a Ruby receiver for App Store Server Notifications V2 and you’ve read Apple’s docs three times without feeling like you’ve actually understood the signature story, this post is for you. We ship a JWS verifier in production at Juice Machine — this is what it does, why each piece is there, and the five things almost every DIY implementation gets wrong.

Length warning: this is a proper deep-dive, ~2,000 words. If you just want the decision explainer instead, we published that in May.

What Apple actually sends

A V2 notification arrives as a JSON POST with a single top-level field:

{ "signedPayload": "eyJhbGciOi...long JWT..." }

signedPayload is a JWS (JSON Web Signature) token signed with ES256 — ECDSA with P-256 and SHA-256. The JWT header carries the signing certificate chain in the x5c field as an array of base64-encoded DER certificates.

Decoding one layer gives you the notification body — notificationType, notificationUUID, signedDate, data, and so on. The data object contains two more JWS tokens: signedTransactionInfo and (on subscription events) signedRenewalInfo. Both are signed with the same chain and need to be decoded the same way. So there are three JWS layers to unwrap, not one.

The two libraries you need

Ruby ships enough OpenSSL to do the certificate chain verification and no more. For the JWS decode itself, use the jwt gem. In your Gemfile:

gem "jwt"

The rough shape of the whole thing:

require "jwt"
require "openssl"
require "base64"

decoded = JWT.decode(signed_payload, nil, true, algorithm: "ES256") do |headers|
  # Return the public key to verify against, given the JWS headers.
  # This block is where the x5c chain gets validated.
end
payload = decoded.first

JWT.decode handles the signature check, but only if you give it the right public key. Extracting that key from the x5c header — and refusing to extract it unless the chain is trustworthy — is the whole game.

Getting Apple Root CA G3

Every trustworthy signing chain terminates at Apple Root CA G3. Download it from Apple’s certificate authority page (search for “Apple Root CA G3”) and commit the DER file (.cer) into your repo, e.g. at lib/certificates/AppleRootCA-G3.cer.

Load it once when your verifier boots:

APPLE_ROOT_CA_G3_PATH = Rails.root.join("lib/certificates/AppleRootCA-G3.cer")

def load_apple_root_certificate
  der_data = File.binread(APPLE_ROOT_CA_G3_PATH)
  OpenSSL::X509::Certificate.new(der_data)
end

Do not fetch it at request time from Apple’s URL. Do not trust the OS trust store. Ship the exact bytes you audited.

Build a small trust store with it as the sole anchor:

def build_cert_store
  store = OpenSSL::X509::Store.new
  store.add_cert(@apple_root_cert)
  store
end

Verifying the chain from the x5c header

The JWT.decode block gets called with the JWT headers. Pull out x5c, decode each entry from base64 to a DER cert, and ask the store to verify the chain:

def extract_and_verify_public_key(headers)
  x5c = headers["x5c"]
  raise JWT::DecodeError, "Missing x5c header" if x5c.blank?

  certificates = x5c.map do |cert_b64|
    OpenSSL::X509::Certificate.new(Base64.decode64(cert_b64))
  end

  raise JWT::DecodeError, "Empty certificate chain" if certificates.empty?

  signing_cert = certificates.first
  intermediate_certs = certificates[1..]

  unless @cert_store.verify(signing_cert, intermediate_certs)
    raise JWT::VerificationError,
          "Certificate chain verification failed: #{@cert_store.error_string}"
  end

  now = Time.current
  unless signing_cert.not_before <= now && now <= signing_cert.not_after
    raise JWT::VerificationError, "Signing certificate is not valid at current time"
  end

  # ...marker OID check goes here, see below...

  signing_cert.public_key
end

Two things here:

  1. The x5c entries are base64 of raw DER, not PEM. Use Base64.decode64(cert_b64) and pass the resulting bytes to OpenSSL::X509::Certificate.new. If you accidentally pass PEM to a code path expecting DER (or vice versa), OpenSSL will raise something opaque like “nested asn1 error” and everyone will have a bad afternoon.
  2. store.verify returns a boolean; the reason for a failure is on store.error_string. Log both. Silent boolean-only verification is unactionable when it fails in production.

This is what most tutorials stop at. And this is where most tutorials leave a large security hole.

The five things almost every tutorial misses

1. The Apple marker OIDs

A chain that ends at Apple Root CA G3 is not enough on its own. Plenty of other Apple-issued certificates chain to the same root — developer distribution certs, Apple Push Notification service certs, App Store signing certs. Any of them could technically be presented in an x5c header and pass a naive chain-only check.

Apple marks each cert’s purpose with an X.509 extension carrying a specific OID. For App Store Server Notifications V2:

  • Leaf marker OID: 1.2.840.113635.100.6.11.1 — must be on the signing cert.
  • Intermediate marker OID: 1.2.840.113635.100.6.2.1 — Apple WWDR CA. Must be on the intermediate.

Only the presence of the extension matters; Apple’s implementation puts a NULL value in it. Check both, refuse the signature if either is absent:

LEAF_MARKER_OID = "1.2.840.113635.100.6.11.1"
INTERMEDIATE_MARKER_OID = "1.2.840.113635.100.6.2.1"

def has_extension_oid?(cert, oid)
  cert.extensions.any? { |ext| ext.oid == oid }
end

# ...in extract_and_verify_public_key:
unless has_extension_oid?(signing_cert, LEAF_MARKER_OID)
  raise JWT::VerificationError,
        "Signing certificate is missing the Apple App Store Server Notifications marker OID"
end

unless intermediate_certs.any? { |cert| has_extension_oid?(cert, INTERMEDIATE_MARKER_OID) }
  raise JWT::VerificationError,
        "Certificate chain is missing an intermediate with the Apple WWDR marker OID"
end

If you take one thing from this post, take this. Apple’s official app-store-server-library does it. Almost none of the third-party tutorials I’ve seen do.

2. The freshness window has to survive Apple’s retry schedule

Apple V2 notifications carry a signedDate (Unix milliseconds). Rejecting anything older than N seconds is standard replay protection. What almost every naive implementation gets wrong is picking N too small.

Apple’s retry schedule for failed V2 notifications is roughly 1h, 12h, 24h, 48h, and 72h after the first attempt — and every retry re-sends the original signedDate, not a fresh one. If your freshness window is 5 minutes, every retry is rejected as stale. A single transient blip on your side (a bad deploy, a database blip, a Sidekiq queue backup) becomes permanent event loss.

Size the window to comfortably cover 72h. We use 7 days:

MAX_SIGNED_DATE_AGE = 7.days

def validate_signed_date_freshness(payload)
  signed_date_ms = payload["signedDate"]
  return nil if signed_date_ms.blank?

  signed_time = Time.at(signed_date_ms / 1000.0).utc
  age = Time.current - signed_time

  if age > MAX_SIGNED_DATE_AGE
    "Notification is too old (#{age.to_i}s, max #{MAX_SIGNED_DATE_AGE.to_i}s)"
  elsif age < -1.minute
    "Notification signedDate is in the future"
  end
end

Replay protection then comes from deduping on notificationUUID at the storage layer, not from a tight freshness window. Two-part idempotency: a UUID unique index in your database, and a wide freshness window that doesn’t fight Apple’s retries.

Also worth noting: the -1.minute clause allows one minute of clock skew for signedDate values that appear slightly in the future. If your host’s clock drifts, you’d otherwise reject legitimate live traffic.

3. There are three nested JWS layers, not one

Once you’ve decoded signedPayload, the inner body has a data object that looks like:

{
  "data": {
    "appAppleId": 123456789,
    "bundleId": "com.example.app",
    "environment": "Production",
    "signedTransactionInfo": "eyJhbGciOi...another JWT...",
    "signedRenewalInfo": "eyJhbGciOi...yet another JWT..."
  }
}

signedTransactionInfo is where the actual transaction lives — product ID, transaction ID, price, appAccountToken, and on iOS 26+ the new commitment plan fields. signedRenewalInfo is where the renewal state lives — auto-renew status, expiration intent, PRICE_INCREASE acceptance status.

Both are JWS-signed by Apple with the same chain type as the outer envelope. Re-use the same verifier on each:

signed_tx = payload.dig("data", "signedTransactionInfo")
tx_info = AppleSignatureVerifier.new.verify!(signed_tx) if signed_tx

signed_renewal = payload.dig("data", "signedRenewalInfo")
renewal_info = AppleSignatureVerifier.new.verify!(signed_renewal) if signed_renewal

If you skip this step, you either don’t have the useful fields at all, or you’re pulling them from the raw JWS string — which means you’re pulling them un-verified.

4. Cross-app replay protection via bundleId

Your webhook URL is (should be) a per-app capability URL — some /hooks/<secret_token> shape. If that token ever leaks, someone could POST a real, correctly-signed notification for a different app at your URL and have your handler process it as if it were yours. The signature would verify.

The mitigation: check the payload’s data.bundleId against the bundle ID you have on file for the app the inbound token maps to:

def validate_bundle_id(payload, expected_bundle_id)
  actual_bundle_id = payload.dig("data", "bundleId")
  return nil if actual_bundle_id.blank?

  if actual_bundle_id != expected_bundle_id
    "Bundle ID mismatch: expected #{expected_bundle_id}, got #{actual_bundle_id}"
  end
end

Cheap check, meaningful defence in depth.

5. Testing without Apple’s actual certificates

You can’t get Apple’s signing cert to write local tests against a real notification. But you can generate self-signed test certs that carry the marker OIDs the verifier checks for, sign fake payloads with them, and stub the trust store’s root anchor for the duration of the test.

def generate_test_key
  OpenSSL::PKey::EC.generate("prime256v1")
end

def generate_test_certificate(key, marker_oid: LEAF_MARKER_OID)
  cert = OpenSSL::X509::Certificate.new
  cert.version = 2
  cert.serial = SecureRandom.random_number(1_000_000)
  cert.subject = OpenSSL::X509::Name.parse("/CN=Test Apple Cert/O=Test/C=US")
  cert.issuer = cert.subject
  cert.public_key = key
  cert.not_before = Time.now - 3600
  cert.not_after = Time.now + 3600 * 24 * 365
  cert.add_extension(
    OpenSSL::X509::Extension.new(marker_oid, OpenSSL::ASN1::Null.new(nil).to_der, false)
  )
  cert.sign(key, OpenSSL::Digest.new("SHA256"))
  cert
end

def create_test_jws(payload, key:, certificate:, intermediate:)
  headers = {
    alg: "ES256",
    x5c: [certificate, intermediate].map { |c| Base64.strict_encode64(c.to_der) }
  }
  JWT.encode(payload, key, "ES256", headers)
end

Then in the test, stub AppleSignatureVerifier’s cert store to trust your self-signed root instead of Apple’s, and you can drive the whole verifier end-to-end with realistic fake payloads. That’s how we cover our verifier’s happy path, the missing-marker case, the wrong-bundle case, and the too-old-notification case.

Putting it together

Roughly the shape of the whole class, elided for space:

class AppleSignatureVerifier
  APPLE_ROOT_CA_G3_PATH = Rails.root.join("lib/certificates/AppleRootCA-G3.cer")
  EXPECTED_ALGORITHM = "ES256"
  LEAF_MARKER_OID = "1.2.840.113635.100.6.11.1"
  INTERMEDIATE_MARKER_OID = "1.2.840.113635.100.6.2.1"
  MAX_SIGNED_DATE_AGE = 7.days

  def initialize
    @apple_root_cert = OpenSSL::X509::Certificate.new(
      File.binread(APPLE_ROOT_CA_G3_PATH)
    )
    @cert_store = OpenSSL::X509::Store.new.tap { |s| s.add_cert(@apple_root_cert) }
  end

  def verify(signed_payload, expected_bundle_id: nil)
    decoded = JWT.decode(signed_payload, nil, true, algorithm: EXPECTED_ALGORITHM) do |headers|
      extract_and_verify_public_key(headers)
    end
    payload = decoded.first

    if (err = validate_signed_date_freshness(payload))
      return Result.failure(err)
    end

    if expected_bundle_id && (err = validate_bundle_id(payload, expected_bundle_id))
      return Result.failure(err)
    end

    Result.success(payload)
  rescue JWT::DecodeError, JWT::VerificationError, OpenSSL::X509::CertificateError => e
    Result.failure(e.message)
  end

  # ...extract_and_verify_public_key, validate_signed_date_freshness,
  # validate_bundle_id as shown above...
end

Wire it into your controller, catch the failure case, respond 401, and log the reason:

def inbound
  result = AppleSignatureVerifier.new.verify(
    request.body.read,
    expected_bundle_id: @app.bundle_id
  )

  return render json: { error: "Invalid signature" }, status: :unauthorized if result.failure?

  payload = result.payload
  # ...persist by notificationUUID (dedup), then fan out.
end

TL;DR

  • App Store Server Notifications V2 are ES256-signed JWS tokens with the certificate chain in the x5c header. Three layers deep — outer envelope, then signedTransactionInfo and signedRenewalInfo re-verified with the same code.
  • Use jwt for the JWS decode and OpenSSL for the chain verification against Apple Root CA G3 (DER, committed into your repo).
  • Chain-to-root alone isn’t enough. Check the Apple marker OIDs on the leaf (1.2.840.113635.100.6.11.1) and intermediate (1.2.840.113635.100.6.2.1) — this is what most naive verifiers skip and it’s a real security hole.
  • Size your freshness window to Apple’s retry schedule (72h+), not to a naive 5-minute threshold. Replay protection lives at the notificationUUID dedup layer, not the freshness window.
  • Cross-check data.bundleId against the app the inbound URL maps to. Allow a minute of clock skew.
  • Or use Juice Machine — we run this verifier in production, on every V2 notification, on the same URL that also accepts App Store Connect webhooks. No signature code to write.