thephpleague/oauth2-server · error · OAuthServerException
client authentication failed
Error message
client authentication failed
What it means
This error is thrown by AbstractGrant::validateClient when ClientRepository::validateClient() returns false, meaning the client credentials supplied with the token request (client_id/client_secret, from body or HTTP Basic auth) do not authenticate for the given grant type. The library emits a CLIENT_AUTHENTICATION_FAILED event and then throws OAuthServerException::invalidClient, surfacing as HTTP 401 with error 'invalid_client'. It is the library's way of rejecting failed confidential-client authentication during respondToAccessTokenRequest.
Solutions
- Verify the client_id and client_secret values sent in the token request match what your ClientRepository::validateClient expects (check env vars, trailing whitespace, quotes).
- Inspect your ClientRepository::validateClient implementation and log why it returns false (unknown client, bad secret, grant not allowed, revoked client).
- If using HTTP Basic auth, confirm the Authorization header is 'Basic base64(urlencode(client_id):urlencode(client_secret))' and that your server does not strip it.
- Ensure the grant type being requested is enabled for that client in your repository lookup.
- Listen for the RequestEvent::CLIENT_AUTHENTICATION_FAILED event to log the client_id and add diagnostics.
Example fix
// before (custom repository rejecting everything)
public function validateClient($clientId, $clientSecret, $grantType): bool {
return $this->clients[$clientId]['secret'] === $clientSecret; // fails when client unknown
}
// after
public function validateClient($clientId, $clientSecret, $grantType): bool {
$client = $this->clients[$clientId] ?? null;
if ($client === null) { return false; }
return hash_equals($client['secret'], (string) $clientSecret)
&& in_array($grantType, $client['grant_types'], true);
} Defensive patterns
Strategy: try-catch
Validate before calling
// caller-side pre-check
if (empty($clientId) || empty($clientSecret)) {
throw new \RuntimeException('client_id and client_secret must be set before token exchange');
} Type guard
function hasClientCredentials(array $params): bool {
return isset($params['client_id'], $params['client_secret'])
&& is_string($params['client_id']) && $params['client_id'] !== ''
&& is_string($params['client_secret']) && $params['client_secret'] !== '';
} Try / catch
try {
$token = $server->respondToAccessTokenRequest($request, $response);
} catch (OAuthServerException $e) {
if ($e->getCode() === 4 || $e->getErrorType() === 'invalid_client') {
// 401: log client_id, surface 'check client credentials' to caller
}
return $e->generateHttpResponse($response);
} Prevention
- Store client secrets out of code and validate env vars at boot (fail fast if empty).
- Use hash_equals for secret comparison in custom repositories.
- Log CLIENT_AUTHENTICATION_FAILED events with client_id for auditing.
- Rotate secrets via a documented deploy step so client and server update together.
When it happens
Trigger: Calling $server->respondToAccessTokenRequest() on a password, client_credentials, refresh_token (with secret required) or authorization_code grant when: the client_secret is wrong or missing; the client_id exists but the secret does not match; validateClient() in the custom ClientRepository explicitly returns false (e.g. the client is revoked, or the grant type is not in the client's allowed list); credentials sent via Basic auth header are mis-decoded or the header is malformed.
Common situations: Secrets rotated in production but not in the client app; env var for client secret missing/empty on deploy; storing secrets with trailing whitespace or quotes picked up from .env; client registered as public but used with a secret (or vice versa) so the repository's validateClient logic rejects it; custom ClientRepository implementations that return false instead of null for unknown clients.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
AI-assisted analysis of thephpleague/oauth2-server@9d2f6fc0a0 (2026-09-15).
Data as JSON: /api/errors/c406b1bf46ed3f7d.
Report an issue: GitHub.
Appendix: source
Thrown at src/Grant/AbstractGrant.php:164
* Validate the client.
*
* @throws OAuthServerException
*/
protected function validateClient(ServerRequestInterface $request): ClientEntityInterface
{
[$clientId, $clientSecret] = $this->getClientCredentials($request);
$client = $this->getClientEntityOrFail($clientId, $request);
if ($client->isConfidential()) {
if ($clientSecret === '') {
throw OAuthServerException::invalidRequest('client_secret');
}
if ($this->clientRepository->validateClient($clientId, $clientSecret, $this->getIdentifier()) === false) {
$this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
throw OAuthServerException::invalidClient($request);
}
}
return $client;
}
/**
* Wrapper around ClientRepository::getClientEntity() that ensures we emit
* an event and throw an exception if the repo doesn't return a client
* entity.
*
* This is a bit of defensive coding because the interface contract
* doesn't actually enforce non-null returns/exception-on-no-client so
* getClientEntity might return null. By contrast, this method will
* always either return a ClientEntityInterface or throw.
*
* @throws OAuthServerException
*/View on GitHub (pinned to 9d2f6fc0a0)