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.
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 Responses::errorResponse('Malformed request');
}
// ...
Next, the helper functions to decode the JWS into its three parts (header, payload and signature), and to encode them again when it's time to verify the signature:
$decodedJWT = JWTReader::decodeJWT($validated['signedPayload']);
// ...
class JWTReader
{
public static function base64UrlDecode($input)
{
$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 Exception('Invalid base64URL encoding');
}
return $decoded;
}
public static function base64UrlEncode($input)
{
return rtrim(strtr(base64_encode(json_encode($input, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)), '+/', '-_'), '=');
}
public static function decodeJWT($jwt)
{
$parts = explode('.', $jwt);
if (count($parts) !== 3) {
throw new Exception('Invalid JWT format');
}
[$header, $payload, $signature] = $parts;
$decodedHeader = json_decode(self::base64UrlDecode($header), true);
$decodedPayload = json_decode(self::base64UrlDecode($payload), true);
if (!$decodedHeader || !$decodedPayload) {
throw new Exception('Invalid JSON in JWT');
}
return [
'header' => $decodedHeader,
'payload' => $decodedPayload,
'signature' => $signature,
];
}
}
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), that the second is signed by the third (Root Certificate), and that the latter is actually issued by Apple's CA.
So let's write a function that downloads the Root Certificate from Apple, or download it manually from https://www.apple.com/certificateauthority/AppleRootCA-G3.cer and save it in the project.
private function fetchAppleRootCertificate()
{
$certUrl = 'https://www.apple.com/certificateauthority/AppleRootCA-G3.cer';
$ch = curl_init($certUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$certData = curl_exec($ch);
curl_close($ch);
if ($certData === false) {
throw new Exception('Failed to download Apple root certificate.');
}
file_put_contents($this->certFilePath, $certData);
exec("openssl x509 -inform DER -in $this->certFilePath -out $this->pemFilePath", $output, $returnVar);
if ($returnVar !== 0) {
throw new Exception('Failed to convert DER to PEM format.');
}
$certificatePEM = $this->getRootCertificateFromStorage();
/** REMOVING CERTIFICATES FROM DISK */
unlink($this->certFilePath);
unlink($this->pemFilePath); /// COMMENT IF YOU ARE NOT CACHING SOMEWHERE ELSE
/** */
/**
* HERE I AM CACHING IN REDIS WITH EXPIRATION IN 7 DAYS
*/
Redis::set('apple_root_certificate', $certificatePEM);
Redis::expire('apple_root_certificate', 7 * 24 * 60 * 60);
return $certificatePEM;
}
How you store it is up to you: on disk, or in a cache with an expiration so it gets downloaded again automatically from time to time.
Now that we have Apple's Root Certificate, we can move on to the actual Certificate Chain verification:
private function getCachedAppleRootCertificate()
{
return Redis::get('apple_root_certificate') ?? $this->fetchAppleRootCertificate();
/**
* IF YOU ARE NOT CACHING IN MEMORY
*/
// return $this->getRootCertificateFromStorage() ?? $this->fetchAppleRootCertificate();
}
private function getRootCertificateFromStorage()
{
$tempPEM = file_get_contents($this->pemFilePath);
if (!$tempPEM) {
return null;
}
$certResource = openssl_x509_read($tempPEM);
if (!$certResource) {
return null;
}
return openssl_x509_export($certResource, $certificatePEM) ? $certificatePEM : null;
}
private function getPEMFromX5C($x5c)
{
return "-----BEGIN CERTIFICATE-----\n" . chunk_split($x5c, 64, "\n") . "-----END CERTIFICATE-----\n";
}
private function verifyCertificateChain($decodedPayload)
{
$appleRootCertPEM = $this->getCachedAppleRootCertificate();
$leafCertPEM = $this->getPEMFromX5C($decodedPayload['header']['x5c'][0]);
$intermediateCertPEM = $this->getPEMFromX5C($decodedPayload['header']['x5c'][1]);
$rootCertPEM = $this->getPEMFromX5C($decodedPayload['header']['x5c'][2]);
if (trim($rootCertPEM) !== trim($appleRootCertPEM)) {
Log::error('Root certificate does not match Apple Root CA, cleaning cache and downloading fresh root cert');
Redis::del('apple_root_certificate');
/** UNCOMMENT IF NOT USING REDIS */
// unlink($this->pemFilePath);
$appleRootCertPEM = $this->getCachedAppleRootCertificate();
if (trim($rootCertPEM) !== trim($appleRootCertPEM)) {
Log::error('Root certificate does not match Apple Root CA');
return null;
}
}
$leafCert = openssl_x509_read($leafCertPEM);
$intermediateCert = openssl_x509_read($intermediateCertPEM);
$rootCert = openssl_x509_read($rootCertPEM);
if (!$leafCert || !$intermediateCert || !$rootCert) {
Log::error('Failed to load certificates');
return null;
}
if (!openssl_x509_verify($leafCert, $intermediateCert)) {
Log::error('Leaf certificate is not signed by Intermediate certificate');
return null;
}
if (!openssl_x509_verify($intermediateCert, $rootCert)) {
Log::error('Intermediate certificate is not signed by Root certificate');
return null;
}
return $leafCert;
}
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 computed over the header and payload segments as they arrived, base64url-encoded: that's why we encode them again with base64UrlEncode before verifying it.
private function extractCertificatePublicKey($leafCertPEM)
{
$cert = openssl_x509_read($leafCertPEM);
if (!$cert) {
Log::error('Invalid leaf certificate, unable to read.');
return false;
}
$publicKeyResource = openssl_pkey_get_public($cert);
if (!$publicKeyResource) {
Log::error('Failed to extract public key from leaf certificate');
return false;
}
$keyDetails = openssl_pkey_get_details($publicKeyResource);
if (!$keyDetails || !isset($keyDetails['key'])) {
Log::error('Failed to retrieve public key details');
return false;
}
return $keyDetails['key'];
}
private function verifyAppleSignature($decodedPayload, $leafCertPEM)
{
$publicKey = $this->extractCertificatePublicKey($leafCertPEM);
if (!$publicKey) {
Log::error('Failed to extract public key from leaf certificate.');
return false;
}
$decodedSignature = JWTReader::base64UrlDecode($decodedPayload['signature']);
if (!$decodedSignature) {
Log::error('Failed to decode base64 signature.');
return false;
}
$signature = $this->convertSignatureToDER($decodedSignature);
if (!$signature) {
Log::error('Signature conversion failed.');
return false;
}
$dataToVerify = JWTReader::base64UrlEncode($decodedPayload['header']) . '.' . JWTReader::base64UrlEncode($decodedPayload['payload']);
$verificationResult = openssl_verify($dataToVerify, $signature, $publicKey, OPENSSL_ALGO_SHA256);
if ($verificationResult === 1) {
return true;
} elseif ($verificationResult === 0) {
Log::error('Apple signature verification failed.');
return false;
} else {
Log::error('Error verifying Apple signature: ' . openssl_error_string());
return false;
}
}
private function convertSignatureToDER(string $signature): string
{
if (strlen($signature) % 2 !== 0) {
Log::error('Invalid signature length: ' . strlen($signature));
return false;
}
$len = strlen($signature) / 2;
$r = substr($signature, 0, $len);
$s = substr($signature, $len);
if (!$r || !$s) {
Log::error('Invalid signature components (r or s missing)');
return false;
}
$r = ltrim($r, "\x00");
$s = ltrim($s, "\x00");
if (strlen($r) > 0 && ord($r[0]) > 0x7f) {
$r = "\x00" . $r;
}
if (strlen($s) > 0 && ord($s[0]) > 0x7f) {
$s = "\x00" . $s;
}
return "\x30" . chr(strlen($r) + strlen($s) + 4) .
"\x02" . chr(strlen($r)) . $r .
"\x02" . chr(strlen($s)) . $s;
}
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);
}
$validated = $validator->validated();
try {
$decodedNotifyJWS = JWTReader::decodeJWT($validated['signedPayload']);
} catch (Exception $e) {
Log::error('Notify JWS Decoding Failed: ' . $e->getMessage());
return response()->json(['message' => 'Invalid JWT'], 422);
}
if (!isset($decodedNotifyJWS['header']) || !isset($decodedNotifyJWS['payload']) || !isset($decodedNotifyJWS['signature'])) {
Log::error('Notify JWS not invalid');
return response()->json(['message' => 'Invalid JWT'], 422);
}
try {
$decodedPurchaseJWS = JWTReader::decodeJWT($decodedNotifyJWS['payload']['data']['signedTransactionInfo']);
} catch (Exception $e) {
Log::error('Purchase JWS Decoding Failed: ' . $e->getMessage());
return response()->json(['message' => 'Invalid JWT'], 422);
}
if (!isset($decodedPurchaseJWS['header']) || !isset($decodedPurchaseJWS['payload']) || !isset($decodedPurchaseJWS['signature'])) {
Log::error('Purchase JWS not invalid');
return response()->json(['message' => 'Invalid JWT'], 422);
}
switch ($this->validatedSignedJWS($decodedNotifyJWS)) {
case 1:
Log::error('Notify Certificate chain verification failed');
break;
case 2:
Log::error('Notify Signature verification failed');
break;
default:
break;
}
switch ($this->validatedSignedJWS($decodedPurchaseJWS)) {
case 1:
Log::error('Purchase Certificate chain verification failed');
break;
case 2:
Log::error('Purchase Signature verification failed');
break;
default:
break;
}
$notifyData = $decodedNotifyJWS['payload'];
$purchaseData = $decodedPurchaseJWS['payload'];
/**
* YOUR CODE HERE
*
* READ AND HANDLE PURCHASE
*/
}
private function validatedSignedJWS($decodedPayload)
{
$leafCertPEM = $this->verifyCertificateChain($decodedPayload);
if ($leafCertPEM == null) return 1;
if (!$this->verifyAppleSignature($decodedPayload, $leafCertPEM)) return 2;
return 0;
}
In short: make sure the POST contains the signedPayload key (the JWS) and let handle decode it and pass it to validatedSignedJWS.