Skip to content

Instantly share code, notes, and snippets.

@vsg24
Created March 5, 2025 15:29
Show Gist options
  • Save vsg24/0d8384fe5e37c6d82d9555176a6fdff7 to your computer and use it in GitHub Desktop.
Save vsg24/0d8384fe5e37c6d82d9555176a6fdff7 to your computer and use it in GitHub Desktop.
Verify Apple Login JWT in Laravel
<?php
namespace App\Actions;
use Firebase\JWT\JWK;
use Illuminate\Support\Facades\Cache;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use Exception;
class AppleLogin
{
// Apple JWKS endpoint
const string APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys';
/**
* Fetch Apple public keys (JWKs). Cache them for performance.
*/
public static function fetchAppleJWKs(): array
{
$cacheKey = 'apple_jwks';
// Remember for 72 hours (Apple rarely rotates keys)
return Cache::remember($cacheKey, 60 * 60 * 72, function () {
$client = new Client();
$response = $client->get(self::APPLE_JWKS_URL);
$body = $response->getBody()->getContents();
$json = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('Unable to parse Apple JWKs');
}
return $json ?? [];
});
}
public static function verifyAppleIdToken(string $idToken, array|string $clientId, ?string $nonce = null): object
{
// Extract 'kid' from token header
$headerBase64 = explode('.', $idToken)[0];
$header = JWT::jsonDecode(JWT::urlsafeB64Decode($headerBase64));
$kid = $header->kid;
// Fetch and parse JWKS
$jwks = self::fetchAppleJWKs();
$publicKeys = JWK::parseKeySet($jwks);
// Check if key exists for this 'kid'
if (!isset($publicKeys[$kid])) {
throw new Exception("Unable to find matching key for kid: {$kid}");
}
$key = $publicKeys[$kid];
// Decode and verify the token
try {
$payload = JWT::decode($idToken, $key);
} catch (Exception $e) {
throw new Exception('Invalid token signature or claims: ' . $e->getMessage());
}
// Additional claim verifications
if (!isset($payload->iss) || $payload->iss !== 'https://appleid.apple.com') {
throw new Exception("Invalid issuer: expected https://appleid.apple.com");
}
$audArray = is_array($payload->aud) ? $payload->aud : [$payload->aud];
$clientIds = is_array($clientId) ? $clientId : [$clientId];
// Check for at least one match
$matchedAud = array_intersect($audArray, $clientIds);
if (empty($matchedAud)) {
throw new Exception("Invalid audience: " . implode(',', $audArray));
}
// If you used a nonce when making the request, verify it:
if ($nonce && (!isset($payload->nonce) || $payload->nonce !== $nonce)) {
throw new Exception('Nonce does not match.');
}
// Also check expiration time if you want to ensure it’s not expired:
if (!isset($payload->exp) || (time() > $payload->exp)) {
throw new Exception('Token has expired.');
}
return $payload;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment