thephpleague/oauth2-server · error · OAuthServerException

expired_token

expired_token

Error message

The `device_code` has expired and the device authorization session has concluded.

What it means

The device code was valid but its expiry timestamp has passed, ending the device authorization session. validateDeviceCode() compares time() against getExpiryDateTime() and throws expiredToken('device_code') (RFC 8628 expiry). The client must restart the device flow with a new code.

Solutions

  1. Client: stop polling on expired_token and restart the device authorization flow to get a fresh code
  2. Increase the device code TTL if your users legitimately need longer (configure the grant's device code lifetime)
  3. Check server clock synchronization (NTP) so expiry comparisons are accurate
  4. Handle expired_token distinctly from authorization_pending in the polling loop — it is terminal, not retryable

Example fix

// before
if (err.error) { await sleep(interval); retry(); } // retries forever

// after
if (err.error === 'expired_token') { startDeviceFlow(); return; } // new code required
Defensive patterns

Strategy: try-catch

Validate before calling

if ($entity !== null && time() > $entity->getExpiryDateTime()->getTimestamp()) { restartDeviceFlow(); }

Try / catch

try { pollToken(); } catch (OAuthServerException $e) { if ($e->getErrorType() === 'expired_token') { restartDeviceFlow('Device code expired'); return; } throw $e; }

Prevention

When it happens

Trigger: respondToAccessTokenRequest polling a device_code after DeviceCodeGrant's deviceCodeTTL (default ~10 minutes) elapsed; user took too long to complete verification; client kept polling past expiry instead of stopping.

Common situations: Long approval delays (user away, email verification flow slow); clocks skewed between server nodes making codes appear expired early; client retries indefinitely on authorization_pending without checking expiry; shortened TTL configured on the server.

Related errors


AI-assisted analysis of thephpleague/oauth2-server@9d2f6fc0a0 (2026-09-15). Data as JSON: /api/errors/17fc606d627acd6e. Report an issue: GitHub.

Appendix: source

Thrown at src/Grant/DeviceCodeGrant.php:209

    {
        $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
    {
        return $lastPoll !== null && $lastPoll->getTimestamp() + $this->retryInterval > time();
    }

View on GitHub (pinned to 9d2f6fc0a0)