Skip to content

Microsoft: unresolvable id_token "kid" still hard-fails login (4.7.0–4.10.0) — the #1425 retry only covers the stale-cache case #1478

Description

@stephenstack

Summary

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.

  • Affected: 4.7.0, 4.7.1, 4.8.0, 4.9.0, 4.9.1, 4.10.0
  • Last known good: 4.6.0 (no id_token validation at all)
  • Reproduced with laravel/socialite 5.30.0, socialiteproviders/manager 4.10.0,
    firebase/php-jwt 7.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:

// Provider.php:448-457 (4.9.1)
try {
    $jwtPayload = JWT::decode($idToken, JWK::parseKeySet($this->getJWTKeysWithCache(false), $alg), $headers);
} catch (\UnexpectedValueException $e) {
    if (str_contains($e->getMessage(), '"kid" invalid') && str_contains($e->getMessage(), 'unable to lookup correct key')) {
        $jwtPayload = JWT::decode($idToken, JWK::parseKeySet($this->getJWTKeysWithCache(true), $alg), $headers);
    } else {
        throw $e;
    }
}

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Cross-references: #1402 (original report), #1425 (partial fix, 4.9.0), #1455 (getClaims/getGroups, 4.10.0).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions