You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Since 4.7.0, Socialite::driver('microsoft')->user() throws and aborts login whenever the id_token's kid cannot be found in the JWKS the provider fetches:
Laravel\Socialite\Two\InvalidStateException: Error on validating id_token.
UnexpectedValueException: "kid" invalid, unable to lookup correct key
in vendor/firebase/php-jwt/src/JWT.php:497
This is the regression originally reported in #1402. That issue was closed by #1425 (released in
4.9.0), which added a JWKS cache plus a force-refresh-and-retry. The retry only fixes the
stale-cache subcase. Reports of the original failure continued on 4.9.1 (see #1402, comment of
2026-04-26), and we are seeing it again in production on 4.9.1.
getJWTKeysWithCache(true) re-fetches the same jwks_uri. If the kid was stale in our cache,
this works and #1425 did fix that. If the kid is not in the document Microsoft serves at that URI,
attempt 2 is byte-for-byte attempt 1 and throws identically. There is no second key source and no
fallback — so the failure mode reported in #1402 survives unchanged.
Two plausible causes ruled out
Recording these so nobody re-treads them:
1. firebase/php-jwt silently dropping the key — no.
In php-jwt 7.1.0, JWK::parseKey() returns null (silently skipping a key) only for known
unsupported EC curves; every other path either returns a Key or throws. Microsoft publishes RSA
keys exclusively ("kty":"RSA", "use":"sig", and notably no "alg" member, which is why $defaultAlg matters). parseKeySet() therefore returns every key in the document. When we get "kid" invalid, the key genuinely is not in the fetched set.
2. The missing appid query parameter on discovery — no.
Microsoft's docs note that apps with custom signing keys (claims-mapping) must append appid={client_id} to the OIDC metadata request to get an app-specific jwks_uri, and Microsoft
does return a different URI when you do:
GET /{tenant}/v2.0/.well-known/openid-configuration
-> jwks_uri: https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys
GET /{tenant}/v2.0/.well-known/openid-configuration?appid={client_id}
-> jwks_uri: https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys?appid={client_id}
But fetching both for an affected tenant, the appid-scoped set is a strict subset: 4 keys
versus 6, with no kids unique to the app-scoped set. So adding appid would give the provider fewer candidate keys, not the missing one. It is not the fix.
The underlying design problem
getUserByToken() calls getRoles() unconditionally (Provider.php:167 in 4.9.1), and 4.10.0 adds getGroups() alongside it (Provider.php:169-170). Both funnel into validate(). So every
application pays for full id_token signature validation on every login, including the majority
that never read roles, groups, or tenant.
For those applications the validation is not a security control — it is only a failure mode. The
authenticated identity comes from the Graph /me response, which is authorised by the access token
obtained through the code exchange. Microsoft's own signing key rollover guidance
puts them explicitly in the category that needn't verify token signatures:
Applications that are only accessing resources (for example, Microsoft Graph, KeyVault, Outlook
API, and other Microsoft APIs) only obtain a token and pass it along to the resource owner. Given
that they aren't protecting any resources, they don't inspect the token and therefore don't need to
ensure it's properly signed.
A claim-extraction convenience has become a hard dependency of the login path. That, rather than the
kid lookup itself, is what turned 4.7.0 into a breaking change.
Proposed fix, in priority order
Make claim extraction non-fatal. On validation failure, return empty claims and log a warning
rather than throwing out of user(). Optionally gate strict behaviour behind a config key
(validate_id_token, default lenient) for apps that do want to enforce it. This restores 4.6.0
behaviour for everyone who reads no claims, while keeping the feature for those who do.
Only validate when claims are requested. Drop the eager getRoles()/getGroups() calls from getUserByToken() and resolve claims lazily on first access, so apps that never touch them never
enter the JWT path.
Follow Microsoft's documented caching algorithm. The current cache is a single 300-second blob
per jwks_uri. Microsoft recommends caching keys individually by kid with a 24-hour TTL, an
hourly background refresh, a dynamic refresh on an unknown kid rate-limited to once per 5
minutes, and retaining the last known good key set when a fetch fails. The current Cache::forget() + immediate refetch is also an unbounded outbound-request amplifier under a
login storm with a genuinely unknown kid.
Put diagnostics in the exception. Include the token's kid, the jwks_uri actually used, and
the kids returned by it. Right now the message is "kid" invalid, unable to lookup correct key
with no context, so no downstream user can diagnose their own failure — every report has to start
with hand-instrumenting the vendor directory. This is the cheapest change here and would have made JWT validation fails when Azure signing key not found in JWKS during key rotation (regression in SocialiteProviders/Microsoft 4.7.0) #1402 diagnosable in one round trip instead of ten months.
Workaround
composer require socialiteproviders/microsoft:4.6.0 # exact pin, not ^4.6
An exact pin matters: ^4.6 resolves straight back to 4.10.0. Note 4.6.0 requests only the User.Read scope (4.7.0+ requests openid profile User.Read), so no id_token is issued at all,
and getRoles()/getGroups()/isConsumerTenant() do not exist on 4.6.0.
Offer
Happy to put up the PR for items 1 and 4 (non-fatal claim extraction plus exception diagnostics) if
maintainers agree on the shape — in particular whether the default should be lenient, or strict with
an opt-out. I wrote #1425, so I'd rather finish the job properly than leave the partial fix standing.
Summary
Since 4.7.0,
Socialite::driver('microsoft')->user()throws and aborts login whenever theid_token'skidcannot be found in the JWKS the provider fetches:This is the regression originally reported in #1402. That issue was closed by #1425 (released in
4.9.0), which added a JWKS cache plus a force-refresh-and-retry. The retry only fixes the
stale-cache subcase. Reports of the original failure continued on 4.9.1 (see #1402, comment of
2026-04-26), and we are seeing it again in production on 4.9.1.
id_tokenvalidation at all)laravel/socialite5.30.0,socialiteproviders/manager4.10.0,firebase/php-jwt7.1.0, PHP 8.4, Laravel 13. Also reported on Laravel 11 / socialite 5.26.1.Why the current retry cannot fix this
Provider::validate()in 4.9.1:getJWTKeysWithCache(true)re-fetches the samejwks_uri. If thekidwas stale in our cache,this works and #1425 did fix that. If the
kidis not in the document Microsoft serves at that URI,attempt 2 is byte-for-byte attempt 1 and throws identically. There is no second key source and no
fallback — so the failure mode reported in #1402 survives unchanged.
Two plausible causes ruled out
Recording these so nobody re-treads them:
1.
firebase/php-jwtsilently dropping the key — no.In php-jwt 7.1.0,
JWK::parseKey()returnsnull(silently skipping a key) only for knownunsupported EC curves; every other path either returns a
Keyor throws. Microsoft publishes RSAkeys exclusively (
"kty":"RSA","use":"sig", and notably no"alg"member, which is why$defaultAlgmatters).parseKeySet()therefore returns every key in the document. When we get"kid" invalid, the key genuinely is not in the fetched set.2. The missing
appidquery parameter on discovery — no.Microsoft's docs note that apps with custom signing keys (claims-mapping) must append
appid={client_id}to the OIDC metadata request to get an app-specificjwks_uri, and Microsoftdoes return a different URI when you do:
But fetching both for an affected tenant, the
appid-scoped set is a strict subset: 4 keysversus 6, with no kids unique to the app-scoped set. So adding
appidwould give the providerfewer candidate keys, not the missing one. It is not the fix.
The underlying design problem
getUserByToken()callsgetRoles()unconditionally (Provider.php:167in 4.9.1), and 4.10.0 addsgetGroups()alongside it (Provider.php:169-170). Both funnel intovalidate(). So everyapplication pays for full
id_tokensignature validation on every login, including the majoritythat never read
roles,groups, ortenant.For those applications the validation is not a security control — it is only a failure mode. The
authenticated identity comes from the Graph
/meresponse, which is authorised by the access tokenobtained through the code exchange. Microsoft's own
signing key rollover guidance
puts them explicitly in the category that needn't verify token signatures:
A claim-extraction convenience has become a hard dependency of the login path. That, rather than the
kid lookup itself, is what turned 4.7.0 into a breaking change.
Proposed fix, in priority order
rather than throwing out of
user(). Optionally gate strict behaviour behind a config key(
validate_id_token, default lenient) for apps that do want to enforce it. This restores 4.6.0behaviour for everyone who reads no claims, while keeping the feature for those who do.
getRoles()/getGroups()calls fromgetUserByToken()and resolve claims lazily on first access, so apps that never touch them neverenter the JWT path.
per
jwks_uri. Microsoft recommends caching keys individually bykidwith a 24-hour TTL, anhourly background refresh, a dynamic refresh on an unknown
kidrate-limited to once per 5minutes, and retaining the last known good key set when a fetch fails. The current
Cache::forget()+ immediate refetch is also an unbounded outbound-request amplifier under alogin storm with a genuinely unknown kid.
kid, thejwks_uriactually used, andthe kids returned by it. Right now the message is
"kid" invalid, unable to lookup correct keywith no context, so no downstream user can diagnose their own failure — every report has to start
with hand-instrumenting the vendor directory. This is the cheapest change here and would have made
JWT validation fails when Azure signing key not found in JWKS during key rotation (regression in SocialiteProviders/Microsoft 4.7.0) #1402 diagnosable in one round trip instead of ten months.
Workaround
composer require socialiteproviders/microsoft:4.6.0 # exact pin, not ^4.6An exact pin matters:
^4.6resolves straight back to 4.10.0. Note 4.6.0 requests only theUser.Readscope (4.7.0+ requestsopenid profile User.Read), so noid_tokenis issued at all,and
getRoles()/getGroups()/isConsumerTenant()do not exist on 4.6.0.Offer
Happy to put up the PR for items 1 and 4 (non-fatal claim extraction plus exception diagnostics) if
maintainers agree on the shape — in particular whether the default should be lenient, or strict with
an opt-out. I wrote #1425, so I'd rather finish the job properly than leave the partial fix standing.
Cross-references: #1402 (original report), #1425 (partial fix, 4.9.0), #1455 (getClaims/getGroups, 4.10.0).