mikebronner/laravel-sign-in-with-apple
A Laravel package that adds Sign in with Apple authentication to your app. It handles Apple's token verification, user lookup and creation, and integrates cleanly with Laravel's existing auth scaffolding and Socialite-style flows.
1. Install and publish
composer require mikebronner/laravel-sign-in-with-apple
php artisan vendor:publish --tag=apple-config # or the package's published tag
13.x consolidates configuration under config/services.php → services.apple. Set the credentials as env vars and reference them there:
// config/services.php
'apple' => [
'client_id' => env('APPLE_CLIENT_ID'), // your Services ID, e.g. com.example.web
'team_id' => env('APPLE_TEAM_ID'),
'key_id' => env('APPLE_KEY_ID'),
'private_key' => env('APPLE_PRIVATE_KEY'), // contents of the .p8, or a path
'redirect' => env('APPLE_REDIRECT_URI'),
],
2. Where to look first
config/services.php → the apple block is the single source of truth in 13.x. Everything else reads from it.php artisan route:list | grep -i apple to see exactly what was registered and under which middleware group.redirect against them.3. First use case
Drop a "Sign in with Apple" button on your login page that hits the package's redirect route, then handle the callback by finding-or-creating a user:
// routes/web.php
Route::get('/auth/apple/redirect', fn () => Socialite::driver('sign-in-with-apple')->redirect());
Route::get('/auth/apple/callback', function () {
$appleUser = Socialite::driver('sign-in-with-apple')->user();
$user = User::firstOrCreate(
['apple_id' => $appleUser->getId()],
[
'name' => $appleUser->getName(),
'first_name' => $appleUser->getFirstName(), // new in 13.0.0
'last_name' => $appleUser->getLastName(), // new in 13.0.0
'email' => $appleUser->getEmail(),
]
);
Auth::login($user);
return redirect()->intended('/dashboard');
})->middleware(['web']);
That's the whole happy path. Everything below is what you'll actually spend your time on.
Prefer the auto-registered routes until you outgrow them. 13.0.0 registers the redirect and callback routes and controller for you. Use them for a first integration, then take over by pointing the callback at your own controller (or disabling route registration in config) once you need custom post-login redirects, tenant resolution, or logging. Don't half-override: pick one.
Treat the callback as a state machine. The reliable shape is:
apple_id (the sub claim). If found → log in, optionally refresh email.AppleSignedIn event; do account linking, onboarding, MFA prompts, etc. in listeners rather than in the controller.Cache the client secret. The new PHP client_secret JWT generator (#68) removes the need for the Ruby/PHP helper script you previously ran out-of-band. Generate on demand and cache it:
$secret = Cache::remember('apple.client_secret', now()->addDays(150), fn () =>
AppleClientSecret::generate(
teamId: config('services.apple.team_id'),
keyId: config('services.apple.key_id'),
clientId: config('services.apple.client_id'),
privateKey: config('services.apple.private_key'),
)
);
Regenerating per request is wasteful (ES256 signing + it's called on every token exchange and revocation check). Regenerating never is worse — Apple caps the lifetime at 6 months.
Wrap the callback in a typed catch. 13.0.0 adds descriptive exceptions for token errors (#65), invalid_client (#73), missing authorization code (#72), and 5xx upstream failures (#75). Map them explicitly instead of swallowing everything:
try {
$appleUser = Socialite::driver('sign-in-with-apple')->user();
} catch (MissingAuthorizationCodeException $e) {
return redirect()->route('login')->withErrors(['apple' => 'Sign in was cancelled.']);
} catch (InvalidClientException $e) {
report($e); // your client_secret / team / key config is wrong
return back()->withErrors(['apple' => 'Apple sign in is misconfigured.']);
} catch (AppleServerException $e) {
report($e); // 5xx — transient, safe to retry
return back()->withErrors(['apple' => 'Apple is unavailable, try again.']);
}
The distinction matters operationally: invalid_client is your bug and pages someone; a 5xx is Apple's and should not alarm.
Handle revocation as a real feature, not an afterthought. #74 adds handling for Apple's account-deletion / access-revocation notifications. Expose the endpoint Apple calls, verify the signature, and treat it as idempotent:
// job dispatched from the revocation handler
User::where('apple_id', $payload['sub'])
->update(['apple_id' => null, 'apple_revoked_at' => now()]);
Revoke sessions and any refresh tokens you store. Handle "user not found" silently — Apple will retry, and you don't want a 500 loop.
Test by faking the HTTP layer, not the driver. Http::fake() around the token endpoint plus a locally-signed JWT for the id_token gets you a fast, honest test of your callback without touching Apple. Build a trait that boots a fake private key and asserts both the success path and each new exception path — the descriptive exceptions are only useful if you actually handle them.
Config-driven per environment. Because everything routes through services.apple, you can define a distinct Services ID per environment (Apple requires separate IDs for staging vs production anyway). Keep redirect absolute HTTPS and environment-specific.
The config key move is the breaking change. 13.0.0 shortens config to services.apple (#64). If you're upgrading from 12.x, any config('services.apple_sign_in...') or package-namespaced key will silently return null — which surfaces as invalid_client at login time rather than an error at boot. Grep your entire codebase and every .env for the old keys, re-publish the config, and add a config assertion to your deploy checklist or a Pest test that config('services.apple.client_id') is non-null in each environment.
Apple sends first_name/last_name exactly once — ever. They arrive on the very first authorization only, in the user payload of the initial POST, not in the id_token. If you don't persist them on that request, they are gone forever. The new getFirstName()/getLastName() accessors (#71) are only useful if you write them to the database in the same request that reads them. Never treat them as refreshable profile data.
The callback is a form_post, not a GET. Apple POSTs to your redirect URI. That's the source of the CSRF mismatch fixed in #67 — but if you're on a custom controller, custom route, or non-standard middleware stack, you may still need the callback path excluded from VerifyCsrfToken and possibly treated as stateless. Symptom: 419 on the callback while everything else looks fine.
Redirect URL validation is now strict (#70). Apple requires HTTPS, a real domain (no localhost, no IPs), no query string, no fragment, and an exact match against what's registered in the Apple Developer portal. Trailing slash mismatches count. 13.0.0 throws early instead of failing opaquely later — read the exception message, it's telling you precisely which rule you violated.
invalid_client almost always means one of four things: rotated .p8 key not updated, team_id/key_id swapped, expired client secret, or a client_id that doesn't match the Services ID in the portal. Check them in that order. The new exception (#73) makes it loud, but it can't tell you which one.
Email may be a private relay address. Users can hide their email; @privaterelay.appleid.com aliases are expected, not a bug. Don't validate against your corporate domain allowlist or attempt to email from a different sending domain without registering it with Apple.
Don't retry on invalid_client or invalid_grant. Only the 5xx path (#75) is safe to retry. Blindly wrapping the exchange in retry(3) will hammer Apple with bad credentials and can get your Services ID rate-limited.
Log the raw Apple response on failure, never the tokens. The new exception messages include enough context to debug most issues; add the request ID Apple returns for support tickets. Redact id_token, access_token, and refresh_token from anything that hits your logs.
Extension points worth knowing: the config flag for route auto-registration (turn it off the moment you need a custom controller), the container binding for the controller if you only want to override one method, and the events the package fires during the flow — hook those rather than editing vendored code. On Laravel 13 / PHP 8.5, verify your whole dependency tree resolves before committing; a package pinned to an older Socialite major can block the upgrade.
How can I help you explore Laravel packages today?