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

  1. Use the exact device_code from the current device authorization response; restart the device flow if it was consumed
  2. Persist device codes in shared storage (Redis/DB) so all server nodes see them
  3. Fix DeviceCodeRepository::getDeviceCodeEntityByDeviceCode to return the hydrated entity for valid codes and null otherwise
  4. 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

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): bool

View on GitHub (pinned to 9d2f6fc0a0)