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.
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:
- The
x5centries are base64 of raw DER, not PEM. UseBase64.decode64(cert_b64)and pass the resulting bytes toOpenSSL::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. store.verifyreturns a boolean; the reason for a failure is onstore.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
x5cheader. Three layers deep — outer envelope, thensignedTransactionInfoandsignedRenewalInfore-verified with the same code. - Use
jwtfor 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
notificationUUIDdedup layer, not the freshness window. - Cross-check
data.bundleIdagainst 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.