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
- Client: stop polling on expired_token and restart the device authorization flow to get a fresh code
- Increase the device code TTL if your users legitimately need longer (configure the grant's device code lifetime)
- Check server clock synchronization (NTP) so expiry comparisons are accurate
- 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
- Treat expired_token as terminal; get a fresh device code
- Keep client polling loops bounded by the code TTL
- Synchronize server clocks with NTP
- Tune the device code TTL to realistic user approval times
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)