Verifying Apple's certificate chain and JWS signature

How to verify the certificate chain and signature of App Store Server Notifications, to guarantee the authenticity and integrity of in-app purchases.

Published on March 6, 2025Updated on October 11, 2026 Code on GitHub

Overview

In this article we go step by step through verifying Apple's certificate chain and validating the payload signature. The full code is available on GitHub.

Apple's servers send a POST request with a JWS that should be verified before its content is trusted. It isn't mandatory, but to be sure the request really comes from Apple and not from a malicious actor, it's best to leave nothing to chance.

What is a JWS and how does it work?

JWS (JSON Web Signature) is an open standard for securely signing JSON data. It guarantees integrity and authenticity: whoever receives the request can verify that the data wasn't altered in transit and that it comes from a trusted source, in our case Apple.

A JWS is made of three parts separated by dots:

  • Header: information about the signature type and the algorithm used.
  • Payload: the actual data, of any kind (for example a JSON with the request details).
  • Signature: the digital signature that guarantees the integrity of the payload and that it really comes from whoever claims to have sent it (in this case Apple).

Apple uses the ES256 algorithm, that is ECDSA (Elliptic Curve Digital Signature Algorithm) with the P-256 curve and the SHA-256 hash function: it hashes the header and payload with SHA-256 and signs the result with a private key based on the P-256 curve. Anyone holding the right public key can verify that the JWS hasn't been tampered with and that it comes from a trusted source.

Hands on the code

When Apple sends the POST request, the body contains the signedPayload key, whose value is our JWS.

So let's set up a POST endpoint in the routes file that expects a body with the signedPayload key:

Route::post('/route/path', [AppleNotificationController::class, 'handle']);

// ...

$validator = Validator::make($request->all(), [
    'signedPayload' => 'required|string',
]);

if ($validator->fails()) {
    Log::error('Validation failed', ['errors' => $validator->errors()]);
    return response()->json(['message' => 'Malformed request'], 422);
}

// ...

Next, the helper functions to decode the JWS into its three parts (header, payload and signature):

class JWTReader
{
    public static function base64UrlDecode(string $input): string
    {
        $input = strtr($input, '-_', '+/');
        $padLength = 4 - (strlen($input) % 4);
        if ($padLength < 4) {
            $input .= str_repeat('=', $padLength);
        }

        $decoded = base64_decode($input, true);
        if ($decoded === false) {
            throw new UnexpectedValueException('Invalid base64URL encoding');
        }

        return $decoded;
    }

    public static function base64UrlEncode(string $input): string
    {
        return rtrim(strtr(base64_encode($input), '+/', '-_'), '=');
    }

    /**
     * Splits a compact JWS and decodes its three parts.
     *
     * `signingInput` is the text the signature covers: header and payload
     * exactly as they arrived, still base64url-encoded. It must not be rebuilt
     * from the decoded arrays, because encoding the same JSON again does not
     * always give back the same bytes.
     *
     * @return array{header: array, payload: array, signature: string, signingInput: string}
     */
    public static function decodeJWT(string $jwt): array
    {
        $parts = explode('.', $jwt);
        if (count($parts) !== 3) {
            throw new UnexpectedValueException('Invalid JWT format');
        }

        [$header, $payload, $signature] = $parts;

        $decodedHeader = json_decode(self::base64UrlDecode($header), true);
        $decodedPayload = json_decode(self::base64UrlDecode($payload), true);

        if (!is_array($decodedHeader) || !is_array($decodedPayload)) {
            throw new UnexpectedValueException('Invalid JSON in JWT');
        }

        return [
            'header' => $decodedHeader,
            'payload' => $decodedPayload,
            'signature' => self::base64UrlDecode($signature),
            'signingInput' => $header . '.' . $payload,
        ];
    }
}

Besides the three parts, decodeJWT returns signingInput: the header and payload exactly as they arrived, still base64url-encoded and joined by a dot. That is the text Apple computes the signature over, so we keep it aside. Rebuilding it from the decoded JSON does not always work: encoding the same JSON again can change spaces, escaped slashes or empty objects, and the signature would no longer match.

Once the JWS is decoded, we check that the header contains the x5c key: an array of three strings, the certificates involved in the process (the Certificate Chain).

For the Certificate Chain to be valid, we need to make sure that:

  • the first certificate (Leaf Certificate) is signed by the second (Intermediate Certificate), and the second by the third (Root Certificate);
  • the Root Certificate really is Apple's;
  • none of the three is expired;
  • Leaf and Intermediate are the ones Apple uses for the App Store.

The last point is easy to forget. Under the same Root Certificate Apple also issues certificates for other purposes, including the ones it gives to developers: without this check, a chain built with one of those would look valid. App Store certificates are recognized by two extensions, identified by their OID: 1.2.840.113635.100.6.11.1 on the Leaf and 1.2.840.113635.100.6.2.1 on the Intermediate.

To recognize Apple's Root Certificate there is no need to download it: its SHA-256 fingerprint is enough. It is public and stays the same until Apple changes certificate (the current one expires in 2039). We keep it in a constant and compare it with the fingerprint of the third certificate of the chain:

/**
 * SHA-256 fingerprint of "Apple Root CA - G3", the certificate every
 * App Store chain must end with. To check it yourself:
 *
 *   curl -s https://www.apple.com/certificateauthority/AppleRootCA-G3.cer \
 *     | openssl x509 -inform DER -noout -fingerprint -sha256
 */
public const APPLE_ROOT_CA_G3_SHA256 = '63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179';

It is the only fixed value in the whole code. Leaf and Intermediate, which Apple renews from time to time, travel inside every notification and are never stored anywhere: they are recognized by who signed them and by the two extensions, which stay the same at every renewal. The Root is where trust starts, so it has to be known in advance: if it could change by itself it would prove nothing. If Apple ever adopts a new one, adding its fingerprint to the configuration is enough, without touching the code.

Now we can move on to the actual Certificate Chain verification:

private function verifyCertificateChain(mixed $x5c, int $at): OpenSSLCertificate
{
    if (!is_array($x5c) || count($x5c) !== 3) {
        throw new UnexpectedValueException('x5c must contain three certificates');
    }

    [$leafCert, $intermediateCert, $rootCert] = array_map(function ($certificate) {
        // @: on text that is not a certificate PHP also raises a warning,
        // which Laravel would turn into a server error.
        $cert = is_string($certificate) ? @openssl_x509_read($this->getPEMFromX5C($certificate)) : false;
        if (!$cert) {
            throw new UnexpectedValueException('Failed to load a certificate of the chain');
        }

        return $cert;
    }, array_values($x5c));

    if (!in_array(openssl_x509_fingerprint($rootCert, 'sha256'), $this->trustedRootFingerprints, true)) {
        throw new UnexpectedValueException('Root certificate is not the Apple Root CA');
    }

    // openssl_x509_verify returns 1, 0 or -1 (on error): only 1 is a pass.
    if (openssl_x509_verify($leafCert, $intermediateCert) !== 1) {
        throw new UnexpectedValueException('Leaf certificate is not signed by the intermediate certificate');
    }

    if (openssl_x509_verify($intermediateCert, $rootCert) !== 1) {
        throw new UnexpectedValueException('Intermediate certificate is not signed by the root certificate');
    }

    $leaf = openssl_x509_parse($leafCert);
    $intermediate = openssl_x509_parse($intermediateCert);
    $root = openssl_x509_parse($rootCert);

    foreach ([$leaf, $intermediate, $root] as $certificate) {
        if (!$certificate || $at < $certificate['validFrom_time_t'] || $at > $certificate['validTo_time_t']) {
            throw new UnexpectedValueException('A certificate of the chain is expired or not yet valid');
        }
    }

    if (!isset($leaf['extensions'][self::LEAF_OID])) {
        throw new UnexpectedValueException('Leaf certificate was not issued for the App Store');
    }

    if (
        !isset($intermediate['extensions'][self::INTERMEDIATE_OID])
        || !str_contains($intermediate['extensions']['basicConstraints'] ?? '', 'CA:TRUE')
    ) {
        throw new UnexpectedValueException('Intermediate certificate is not an Apple certificate authority');
    }

    return $leafCert;
}

private function getPEMFromX5C(string $x5c): string
{
    return "-----BEGIN CERTIFICATE-----\n" . chunk_split($x5c, 64, "\n") . "-----END CERTIFICATE-----\n";
}

Mind openssl_x509_verify: it returns 1 if the signature is valid, 0 if it is not and -1 on error. Compare it with 1, because !-1 is false and an error would pass for a valid signature.

At this point we have the code to verify the Certificate Chain. Let's add the part that checks that the JWS signature is correct, so we can trust the information Apple sent us.

The signature is verified over signingInput, with the public key of the Leaf Certificate. In a JWS an ES256 signature is two 32-byte integers, r and s, written one after the other, while OpenSSL wants them in an ASN.1 DER sequence: that's why we convert it first.

private function verifySignature(string $signingInput, string $signature, OpenSSLCertificate $leafCert): void
{
    $publicKey = openssl_pkey_get_public($leafCert);
    $keyDetails = $publicKey ? openssl_pkey_get_details($publicKey) : false;

    if (
        !$keyDetails
        || $keyDetails['type'] !== OPENSSL_KEYTYPE_EC
        || ($keyDetails['ec']['curve_name'] ?? null) !== 'prime256v1'
    ) {
        throw new UnexpectedValueException('Leaf certificate does not hold a P-256 public key');
    }

    $verificationResult = openssl_verify(
        $signingInput,
        $this->convertSignatureToDER($signature),
        $publicKey,
        OPENSSL_ALGO_SHA256
    );

    if ($verificationResult !== 1) {
        throw new UnexpectedValueException('Signature verification failed');
    }
}

private function convertSignatureToDER(string $signature): string
{
    if (strlen($signature) !== 64) {
        throw new UnexpectedValueException('Invalid signature length: ' . strlen($signature));
    }

    $integers = '';
    foreach (str_split($signature, 32) as $integer) {
        $integer = ltrim($integer, "\x00");

        // A DER integer is signed: a leading zero keeps it positive.
        if ($integer === '' || ord($integer[0]) > 0x7f) {
            $integer = "\x00" . $integer;
        }

        $integers .= "\x02" . chr(strlen($integer)) . $integer;
    }

    return "\x30" . chr(strlen($integers)) . $integers;
}

Let's put the pieces together in a single method, which returns the payload only if every check passes and throws an exception otherwise:

public function verify(string $jws, ?int $at = null): array
{
    $jwt = JWTReader::decodeJWT($jws);

    if (($jwt['header']['alg'] ?? null) !== 'ES256') {
        throw new UnexpectedValueException('Algorithm is not ES256');
    }

    $leafCert = $this->verifyCertificateChain($jwt['header']['x5c'] ?? null, $at ?? time());

    $this->verifySignature($jwt['signingInput'], $jwt['signature'], $leafCert);

    return $jwt['payload'];
}

Now we have everything. Here's the complete flow:

public function handle(Request $request)
{
    $validator = Validator::make($request->all(), [
        'signedPayload' => 'required|string',
    ]);

    if ($validator->fails()) {
        Log::error('Validation failed', ['errors' => $validator->errors()]);
        return response()->json(['message' => 'Malformed request'], 422);
    }

    $signedPayload = $validator->validated()['signedPayload'];

    // Trusts Apple Root CA - G3. Should Apple ever move the App Store to
    // another root, set its fingerprint in services.apple.root_ca_sha256
    // instead of changing the code.
    $verifier = new AppleJWSVerifier(
        (array) (config('services.apple.root_ca_sha256') ?: AppleJWSVerifier::APPLE_ROOT_CA_G3_SHA256)
    );

    try {
        $notifyData = $verifier->verify($signedPayload);

        // What the notification is about. Which of these is present
        // depends on the notification type.
        $details = $notifyData['data']
            ?? $notifyData['summary']
            ?? $notifyData['externalPurchaseToken']
            ?? $notifyData['appData']
            ?? [];

        // Not every notification carries a transaction: a TEST one has none.
        $purchaseData = isset($details['signedTransactionInfo'])
            ? $verifier->verify($details['signedTransactionInfo'])
            : null;

        // Only for auto-renewable subscriptions.
        $renewalData = isset($details['signedRenewalInfo'])
            ? $verifier->verify($details['signedRenewalInfo'])
            : null;
    } catch (UnexpectedValueException $e) {
        Log::error('Apple notification rejected: ' . $e->getMessage());
        return response()->json(['message' => 'Invalid signed payload'], 422);
    }

    // The signature only proves that Apple wrote the notification, not
    // that it is about this app: Apple signs the notifications of every
    // app with the same certificates.
    if (($details['bundleId'] ?? null) !== config('services.apple.bundle_id')) {
        Log::error('Apple notification rejected: it is for another app', ['bundleId' => $details['bundleId'] ?? null]);
        return response()->json(['message' => 'Invalid signed payload'], 422);
    }

    if ($this->isSandbox($details) && app()->environment('production')) {
        return $this->forwardToDevServer($signedPayload);
    }

    /**
     * YOUR CODE HERE
     *
     * READ AND HANDLE PURCHASE
     *
     * Apple sends the same notification again when it gets no answer:
     * $notifyData['notificationUUID'] tells you if you already handled it.
     */

    return response()->json(['message' => 'Notification processed successfully'], 200);
}

If a check fails the request stops there, with an error: logging the problem and then carrying on would make everything else pointless.

There is one more check the signature cannot do for us. Apple signs the notifications of every app with the same certificates: a genuine notification meant for another app would pass every verification. That's why handle compares the bundleId with the one of our app, which lives in config/services.php:

'apple' => [
    'bundle_id' => env('APPLE_BUNDLE_ID'),
    'sandbox_forward_url' => env('APPLE_SANDBOX_FORWARD_URL'),
    'root_ca_sha256' => array_filter(explode(',', (string) env('APPLE_ROOT_CA_SHA256'))),
],

In short: make sure the POST contains the signedPayload key (the JWS) and let handle pass it to verify.

Back to home