thephpleague/oauth2-server · error · OAuthServerException
invalid_grant
invalid_grant
Error message
The authorization grant type is not supported by the authorization server.
What it means
The supplied device_code did not resolve to a DeviceCodeEntityInterface, meaning the authorization server has no record of it. validateDeviceCode() emits USER_AUTHENTICATION_FAILED and throws invalidGrant() with the generic unsupported-grant-type-style message because a device flow token request was made with an unknown device code.
Solutions
- Use the exact device_code from the current device authorization response; restart the device flow if it was consumed
- Persist device codes in shared storage (Redis/DB) so all server nodes see them
- Fix DeviceCodeRepository::getDeviceCodeEntityByDeviceCode to return the hydrated entity for valid codes and null otherwise
- Check pruning jobs and TTLs are not removing codes before their expiry
Example fix
// before (local file cache per server node)
$store = new FileStore('/tmp/device-codes');
// after (shared store)
$store = new RedisStore($redis, 'device_codes'); Defensive patterns
Strategy: try-catch
Validate before calling
$entity = $repo->getDeviceCodeEntityByDeviceCode($code); if ($entity === null) { restartDeviceFlow(); } Type guard
function deviceCodeExists(?DeviceCodeEntityInterface $e): bool { return $e instanceof DeviceCodeEntityInterface; } Try / catch
try { pollToken(); } catch (OAuthServerException $e) { if ($e->getErrorType() === 'invalid_grant') { restartDeviceFlow('Unknown device code'); return; } throw $e; } Prevention
- Restart the flow whenever the device code is unknown or consumed
- Store device codes in shared storage across server nodes
- Never reuse device_code values from previous authorization rounds
- Verify repository read/write backends match
When it happens
Trigger: respondToAccessTokenRequest -> validateDeviceCode with a device_code string that was never issued, already consumed/rotated, deleted by storage pruning, or stored/read via mismatched repository backends (persist to one store, read from another).
Common situations: Client restarted the flow but kept polling with the old device_code; multi-server deployment where device codes are stored in local cache/file instead of shared storage (Redis/DB); codes expired and were garbage-collected; fabricated or truncated codes from a misbehaving client.
Related errors
AI-assisted analysis of thephpleague/oauth2-server@9d2f6fc0a0 (2026-09-15).
Data as JSON: /api/errors/f92e1c2cb0e2bc16.
Report an issue: GitHub.
Appendix: source
Thrown at src/Grant/DeviceCodeGrant.php:205
/**
* @throws OAuthServerException
*/
protected function validateDeviceCode(ServerRequestInterface $request, ClientEntityInterface $client): DeviceCodeEntityInterface
{
$deviceCode = $this->getRequestParameter('device_code', $request);
if (is_null($deviceCode)) {
throw OAuthServerException::invalidRequest('device_code');
}
$deviceCodeEntity = $this->deviceCodeRepository->getDeviceCodeEntityByDeviceCode(
$deviceCode
);
if ($deviceCodeEntity instanceof DeviceCodeEntityInterface === false) {
$this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request));
throw OAuthServerException::invalidGrant();
}
if (time() > $deviceCodeEntity->getExpiryDateTime()->getTimestamp()) {
throw OAuthServerException::expiredToken('device_code');
}
if ($this->deviceCodeRepository->isDeviceCodeRevoked($deviceCode) === true) {
throw OAuthServerException::invalidRequest('device_code', 'Device code has been revoked');
}
if ($deviceCodeEntity->getClient()->getIdentifier() !== $client->getIdentifier()) {
throw OAuthServerException::invalidRequest('device_code', 'Device code was not issued to this client');
}
return $deviceCodeEntity;
}
private function deviceCodePolledTooSoon(?DateTimeImmutable $lastPoll): boolView on GitHub (pinned to 9d2f6fc0a0)