socialiteproviders/apple
Apple provider for Laravel Socialite, enabling Sign in with Apple via a native OAuth provider. Install with Composer, configure your Apple client ID, team ID, key ID, and private key, then authenticate users with a single Socialite call. Maintained by the Socialite Providers community.
socialiteproviders/apple is a thin Socialite driver — nothing more. Read SocialiteProviders\Apple\Provider first: it implements only getAuthUrl, getTokenFields, getUserByToken, and mapUserToObject. Everything else (signing the client secret, JWKS verification, refresh, revocation) is yours. Knowing that up front saves you the afternoon you'd otherwise spend looking for methods that don't exist.
Apple Developer prerequisites. You need an App ID (native) or Services ID (web), the Sign in with Apple capability enabled, a .p8 private key, your Team ID, Key ID, and a registered Return URL. The Return URL must be HTTPS and byte-match your config — a trailing slash in redirect()->route(...) that differs from the console entry produces a redirect-URI mismatch. Use ngrok, expose, or a Cloudflare Tunnel locally; http://localhost will not work.
Install and register:
composer require socialiteproviders/apple socialiteproviders/manager
Laravel 10 and earlier, in app/Providers/EventServiceProvider.php:
protected $listen = [
\SocialiteProviders\Manager\SocialiteWasCalled::class => [
\SocialiteProviders\Apple\AppleExtendSocialite::class.'@handle',
],
];
Laravel 11/12 ship without an EventServiceProvider, so use AppServiceProvider::boot():
use Illuminate\Support\Facades\Event;
use SocialiteProviders\Manager\SocialiteWasCalled;
public function boot(): void
{
Event::listen(function (SocialiteWasCalled $event) {
$event->extendSocialite('apple', \SocialiteProviders\Apple\Provider::class);
});
}
Config plus the secret you have to build yourself:
'apple' => [
'client_id' => env('APPLE_CLIENT_ID'), // Services ID for web
'client_secret' => env('APPLE_CLIENT_SECRET'), // signed ES256 JWT — see below
'redirect' => env('APPLE_REDIRECT_URI'),
],
Apple expects an ES256 JWT with iss = Team ID, sub = client ID, aud = https://appleid.apple.com, kid in the header:
use Firebase\JWT\JWT;
$secret = JWT::encode([
'iss' => config('services.apple.team_id'),
'iat' => now()->timestamp,
'exp' => now()->addDays(180)->timestamp, // Apple's maximum
'aud' => 'https://appleid.apple.com',
'sub' => config('services.apple.client_id'),
], file_get_contents(storage_path('apple/AuthKey_ABC123.p8')), 'ES256', config('services.apple.key_id'));
Routes — the callback is POST:
Route::get('/auth/apple/redirect', [AppleController::class, 'redirect']);
Route::post('/auth/apple/callback', [AppleController::class, 'callback']);
Apple uses response_mode=form_post whenever name/email scopes are requested, and the provider sets that for you. Getting the verb wrong is the most common first-day failure.
First end-to-end use case — web login persisting the sub:
public function redirect()
{
return Socialite::driver('apple')->scopes(['name', 'email'])->redirect();
}
public function callback(Request $request)
{
$appleUser = Socialite::driver('apple')->user();
// Name arrives only on first consent, in the POST body — not the id_token.
$name = null;
if ($request->filled('user')) {
$decoded = json_decode($request->input('user'), true);
$name = trim(($decoded['name']['firstName'] ?? '').' '.($decoded['name']['lastName'] ?? ''));
}
$user = User::firstOrCreate(
['apple_id' => $appleUser->getId()],
['email' => $appleUser->getEmail(), 'name' => $name ?: null]
);
Auth::login($user, true);
return redirect()->intended('/dashboard');
}
Keep the callback controller boring. Call user(), resolve by the Apple sub, never by email. Wrap in try/catch for InvalidStateException and \Throwable from the token exchange so a failed login redirects back with a flash message instead of a 500.
First-login profile capture. Apple returns name and email only on the user's first authorization. The id_token carries email (and email_verified, sometimes the string "true"), but the name only exists in the user form field of the callback POST. Use firstOrCreate, not updateOrCreate — later logins send null and you'll wipe a stored name.
Native/API clients. If an iOS app already holds an id_token:
$user = Socialite::driver('apple')->stateless()->userFromToken($idToken);
Treat this as an identity lookup, not authentication. The provider decodes the JWT payload; that is not signature verification. Verify against Apple's JWKS (https://appleid.apple.com/auth/keys) with firebase/php-jwt and check iss, exp, and aud yourself.
Multiple audiences (6.1.0). When one backend serves both a Services ID (web) and a bundle ID (native), 6.1.0 accepts multiple identity-token audiences. Read the provider source in your pinned version to confirm how the audience list is consumed before wiring it into config — don't assume a single client_id string is still the only validated value.
Capturing refresh tokens for account deletion. App Store rules require revocation on account deletion, and user() hides the raw token response. Do the exchange explicitly:
$response = Socialite::driver('apple')->getAccessTokenResponse($request->input('code'));
$user = Socialite::driver('apple')->userFromToken($response['id_token']);
// store $response['refresh_token'] encrypted
Revoke later with a POST to https://appleid.apple.com/auth/revoke carrying client_id, a freshly signed client_secret, token, and token_type_hint=refresh_token.
Rotate the secret on a schedule. Six-month expiry is easy to forget until it breaks login. Generate on demand and cache:
$secret = Cache::remember('apple.client_secret', now()->addDays(30), fn () => $this->signAppleJwt());
config(['services.apple.client_secret' => $secret]);
Better: an php artisan apple:secret command that writes to cache, called from a scheduler entry. Never drop a static JWT in .env and walk away.
Wrap the provider behind an internal boundary. A small SignInWithApple service exposing redirect(), userFromCallback(Request), and revoke(string $refreshToken) keeps the Socialite surface in one class, gives you a test seam, and makes an eventual exit from the package a one-file change.
Subclass when you need more. Custom token exchange, refresh capture, JWKS verification, or extra audience validation — extend and register your class instead of the base:
class AppleProvider extends \SocialiteProviders\Apple\Provider
{
protected function getUserByToken($token) { /* ... */ }
}
// in the SocialiteWasCalled listener:
$event->extendSocialite('apple', AppleProvider::class);
Testing. Socialite has no built-in fake; mock the driver in feature tests:
Socialite::shouldReceive('driver->user')->andReturn(
(new \Laravel\Socialite\Two\User)->map(['id' => '001234.abcd', 'email' => '[email protected]'])
);
For the callback route, include a user form field in the POST so the first-login name path is actually exercised, and assert the CSRF exclusion is in place.
Route::post and exclude it from CSRF: Laravel 11+ $middleware->validateCsrfTokens(except: ['auth/apple/callback']), Laravel 10 VerifyCsrfToken::$except.SESSION_SAME_SITE=lax, the session cookie isn't sent on the POST and the state check fails. Set SESSION_SAME_SITE=none (requires HTTPS) or go stateless.client_secret is not generated by the package. invalid_client from Apple is nearly always a bad JWT: wrong sub (must be the client ID, not the Team ID), wrong kid, stale exp, or mangled newlines in the .p8. Store the file path and file_get_contents it; if you must use env vars, replace literal \n.->scopes(['name','email']) Apple may return only sub — and scoped requests are what trigger the form_post behaviour above.id_token. It's $request->input('user'), a JSON string, present only on first consent.@privaterelay.appleid.com) and email_verified may arrive as a string. Normalise rather than naively type-casting.aud and iss checks. Valid issuer is https://appleid.apple.com. Supporting both web and native means configuring all expected audiences (see 6.1.0) or you'll reject legitimate tokens.Driver [apple] is not supported means the SocialiteWasCalled listener isn't registered — check the service provider, run php artisan config:clear after config changes.refresh_token; build the revoke call.stateless() disables the state check by design. Use it only for API clients carrying their own id_token, never for the browser flow.id_token into jwt.io (it is not encrypted), and verify aud, iss, exp, sub. Watch for clock skew on exp.SocialiteProviders/Providers monorepo to keep this moving. Read the changelog for provider-behaviour changes (6.1.0's audience handling is exactly the kind of change that alters validation semantics), and keep the internal abstraction so replacement is cheap.How can I help you explore Laravel packages today?