genealabs/laravel-sign-in-with-apple
Add Sign In With Apple to your Laravel app. This package integrates Apple's OAuth flow with Laravel Socialite, handles the Apple developer configuration, and provides a ready-made button and controller. Requires PHP 8.2+, Laravel 10+, and an Apple Developer subscription.
The package is a Socialite driver plus a Blade button component and Apple-specific plumbing (client-secret JWT generation, CSRF exemption for the POST callback, and revocation notifications). You are not adopting a framework — you are wiring Apple's OAuth flow into Laravel's existing Socialite conventions.
Minimum viable setup (in order):
Install
composer require mikebronner/laravel-sign-in-with-apple
Optionally add genealabs/laravel-socialiter if you want automatic user resolution/persistence instead of hand-rolling firstOrCreate.
Get your Apple credentials. You need three artifacts from the Apple Developer portal, and you cannot shortcut any of them:
com.example.service) configured with your Web Domain and a Return URL — this is your callback route..p8) tied to that App ID, downloaded once./.well-known/apple-developer-domain-association.txt.Generate the client secret. Two paths:
use GeneaLabs\LaravelSignInWithApple\Support\ClientSecretGenerator;
$secret = ClientSecretGenerator::generate(
teamId: 'TEAM_ID',
clientId: 'com.example.service',
keyId: 'KEY_ID',
privateKey: file_get_contents(storage_path('keys/AuthKey.p8')),
ttlDays: 180,
);
or, with env vars set (SIGN_IN_WITH_APPLE_TEAM_ID, SIGN_IN_WITH_APPLE_KEY_ID, SIGN_IN_WITH_APPLE_PRIVATE_KEY_PATH):
$secret = ClientSecretGenerator::fromConfig();
This replaces the Ruby jwt gem script documented upstream — prefer it, since it means your secret rotation lives in PHP/scheduler rather than a side-process someone will forget.
Environment
APPLE_REDIRECT="https://example.com/apple/callback"
APPLE_CLIENT_ID="com.example.service"
APPLE_CLIENT_SECRET="eyJraWQiOi..."
Routes + controller (see patterns below) and drop the button in your login view:
@signInWithApple('black', false, 'sign-in', 4)
First use case to build: the two-route login flow — GET /apple/login that calls Socialite::driver('sign-in-with-apple')->scopes(['name','email'])->redirect(), and POST /apple/callback that calls ->user() and either logs in a matching apple_id or creates one. Get that working in APP_ENV=local (http://localhost is the one non-HTTPS URL Apple tolerates) before touching revocation handling.
Where to look first: the README's Controller and CSRF Exclusion sections. Everything painful about this package is route/URL configuration, not PHP.
The two-route shape. Apple's response_mode=form_post means the callback is a POST with form-encoded params, never a GET query string. Your routes must reflect that asymmetry:
Route::get('/apple/login', [AppleSigninController::class, 'login'])->name('apple.login');
Route::post('/apple/callback', [AppleSigninController::class, 'callback'])->name('apple.callback');
The package validates APPLE_REDIRECT at auth initiation and throws InvalidRedirectUrlException if it violates Apple's rules (non-HTTPS in production, query string, fragment). Let that exception fire in dev — it's your fastest config check.
Identity mapping: sub, not email. Apple's sub is the stable unique identifier. The idiomatic schema is a nullable, unique apple_id column on users:
$appleUser = Socialite::driver('sign-in-with-apple')->user();
$user = User::firstOrCreate(
['apple_id' => $appleUser->getId()],
[
'name' => $appleUser->getName(), // only present on FIRST authorization
'email' => $appleUser->getEmail(), // may be a @privaterelay.appleid.com address
]
);
Auth::login($user, remember: true);
Treat name as write-once. Apple sends it on first consent only; every subsequent sign-in returns null. If you're using Socialiter, this is largely handled for you via Socialiter::driver('sign-in-with-apple')->login(), which is the recommended path if you don't already have custom user-resolution logic.
Secret rotation as a scheduled concern. The client secret is a JWT capped at 180 days. The pattern the package explicitly supports:
// app/Console/Commands/RotateAppleSecret.php
$secret = ClientSecretGenerator::fromConfig(ttlDays: 180);
// persist to your secret store, or write to config at boot for single-instance setups
config(['services.sign_in_with_apple.client_secret' => $secret]);
In production with multiple app servers, have the scheduled command push the regenerated JWT into your secret manager (Vault, SSM, encrypted env) rather than mutating in-process config — the in-process config() call only helps the process that ran it. Rotate well before day 180 (e.g. every 150 days) and give yourself a deploy window.
Revocation handling. Register the package's notification controller:
Route::post('/apple/notifications', [AppleNotificationController::class, 'handle'])
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
The controller verifies Apple's JWT signature against https://appleid.apple.com/auth/keys (cached 1 hour), so forged posts are rejected. Then listen for the event:
Event::listen(AppleAccessRevoked::class, function (AppleAccessRevoked $event) {
// $event->sub, $event->eventType ('consent-revoked' | 'account-delete')
User::where('apple_id', $event->sub)->update(['apple_id' => null]);
});
What you do on revocation is a product decision (null the apple_id, deactivate, force re-consent), but you should not delete the account outright — consent-revoked is not the same as a user asking you to delete their data.
Re-authentication after revocation. Apple may issue a new sub when a user revokes and re-authorizes. The Socialite user object carries is_returning_user for exactly this:
if ($appleUser['is_returning_user']) {
$user = User::where('email', $appleUser->getEmail())->firstOrFail();
$user->update(['apple_id' => $appleUser->getId()]); // re-link the new sub
} else {
// first-time path: name is available, create the record
}
This re-link branch is easy to skip and produces duplicate accounts in production. Build it in from day one.
Package name and namespace disagree. Composer package is mikebronner/laravel-sign-in-with-apple, but the PHP namespace is GeneaLabs\LaravelSignInWithApple. Don't waste time looking for a MikeBronner\ namespace — there isn't one. Same applies to the genealabs/laravel-socialiter companion package.
The 419 that isn't your fault. Apple POSTs the callback without a CSRF token. The package auto-excludes the configured callback from VerifyCsrfToken, so you usually need to do nothing — but if you have a custom middleware stack, a route group with extra verification, or a proxied/stripped callback path, you'll get 419 | Page Expired and the authorization code never arrives. If you hit this, check in this order: (1) route is Route::post, (2) package's auto-exclusion is actually matching your path, (3) add the $except entry or ->withoutMiddleware([...]) manually.
"Missing authorization code" is almost always config, not code. The README's triage list is the right one: POST route, CSRF, exact redirect URL match. Add one more: trailing slash and http vs https mismatches between .env and the Apple Developer console. Apple compares byte-for-byte and the error surfaces as a missing code, not as a URL error.
config:cache will bite you. If you regenerate the client secret and then run php artisan config:cache, a stale secret from the cached config can override your freshly generated one. Decide on one source of truth — either the cached env var (rotate by deploy) or your secret manager read at boot — and don't mix.
Private relay emails are real emails, but not stable identifiers. Users who hide their email get [email protected]. That address can change; do not use it as a primary key or a unique constraint. Only sub/apple_id is stable.
Notification endpoint needs to be registered in two places. The route in your app and the notification URL in the Apple Developer Services ID config. If you register only the route, you'll silently receive nothing and revocation events will never fire — there's no error, just absence.
Debugging the callback. Log the raw POST body in your callback controller on first integration. The Socialite driver throws generic errors when the payload shape is unexpected (missing code, state mismatch), and seeing the actual array is far faster than stepping through the driver. Strip the log before shipping — it contains id_token and code.
Extension points worth knowing:
ClientSecretGenerator::generate() for programmatic rotation in tests or one-off scripts, ClientSecretGenerator::fromConfig() for scheduled jobs.AppleAccessRevoked is a plain event — bind multiple listeners (analytics, audit log, notification email) without touching the controller.@signInWithApple is a Blade directive, so you can wrap it in your own component (<x-apple-button />) if you need consistency with an existing design system; the $borderRadius and $hasBorder params are limited, so a custom component is common past the prototype stage.phpunit --testsuite=Unit,Feature runs without extra dependencies; Dusk/browser tests need Chrome installed and are usually not worth wiring into CI for this integration — the package's own browser tests cover the button rendering, not your callback logic.Version pin awareness. Package 5.x covers Laravel 10–13, but Laravel 13 requires PHP 8.3+. If you're on a mixed fleet, pin explicitly in composer.json rather than floating on ^5.0, and re-check the version support table on upgrade — it's updated more reliably than the changelog.
How can I help you explore Laravel packages today?