thephpleague/oauth2-server · error · OAuthServerException
invalid_client
invalid_client
Error message
invalid_client
What it means
The OAuth server rejected the client application's credentials during the client_credentials token request. validateClient() either failed to authenticate the client or the authenticated client is not confidential (has no secret), and ClientCredentialsGrant only issues tokens to confidential clients. An CLIENT_AUTHENTICATION_FAILED event is emitted before the exception is thrown.
Solutions
- Send the client_secret with the request (body parameter or HTTP Basic auth) and confirm it matches the stored hashed secret
- Verify the client entity returned by your ClientRepository::getClientEntity has a non-empty secret and verifySecret passes, making isConfidential() true
- Check that the client record in your storage still exists and its secret hash matches (re-hash/re-save if the hashing algo changed)
- If the client should be public, switch to a grant that supports public clients (e.g. authorization_code with PKCE) instead of client_credentials
Example fix
// before (public client, no secret)
$clients['my-app'] = ['name' => 'my-app', 'redirectUri' => ''];
// after (confidential client with secret)
$clients['my-app'] = new ClientEntity('my-app', 'my-app', '', true);
$clients['my-app']->setSecret($server->encrypt ? password_hash('s3cret', PASSWORD_DEFAULT) : 's3cret'); Defensive patterns
Strategy: try-catch
Validate before calling
if (empty($clientId) || empty($clientSecret)) { throw new \RuntimeException('client_id and client_secret are required for client_credentials'); } Type guard
function isConfidentialClient(?ClientEntityInterface $c): bool { return $c !== null && $c->getSecret() !== null && $c->getSecret() !== ''; } Try / catch
try { $token = $grant->respondToAccessTokenRequest($request, $response); } catch (OAuthServerException $e) { if ($e->getErrorType() === 'invalid_client') { log('client auth failed: check id/secret and that client is confidential'); } throw $e; } Prevention
- Always send client_secret (body or Basic auth) for client_credentials requests
- Register confidential clients with a hashed secret and verify with verifySecret
- Smoke-test credentials after any rotation
- Never use public (secret-less) clients with client_credentials
When it happens
Trigger: Calling respondToAccessTokenRequest on ClientCredentialsGrant when: the client_id/client_secret posted (or sent via HTTP Basic PHP_AUTH_USER/PHP_AUTH_PW) do not match a registered client; the client repository returns null or a client whose secret fails hash verification; or the client resolves but isConfidential() returns false because its secret is null/empty.
Common situations: Client secret changed or rotated on the server but not the consumer; client registered as public (no secret) but used with client_credentials grant which requires a confidential client; missing Basic auth header behind a proxy that strips Authorization; wrong redirect of PHP_AUTH_USER when not using Basic auth; league/oauth2-server v8+ requiring isConfidential() where older versions did not.
Related errors
- client authentication failed
- invalid request: client_secret
- invalid_client
- unauthorized_client
- invalid_request
AI-assisted analysis of thephpleague/oauth2-server@9d2f6fc0a0 (2026-09-15).
Data as JSON: /api/errors/280334fe93c7075e.
Report an issue: GitHub.
Appendix: source
Thrown at src/Grant/ClientCredentialsGrant.php:42
/**
* Client credentials grant class.
*/
class ClientCredentialsGrant extends AbstractGrant
{
/**
* {@inheritdoc}
*/
public function respondToAccessTokenRequest(
ServerRequestInterface $request,
ResponseTypeInterface $responseType,
DateInterval $accessTokenTTL
): ResponseTypeInterface {
$client = $this->validateClient($request);
if (!$client->isConfidential()) {
$this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
throw OAuthServerException::invalidClient($request);
}
$scopes = $this->validateScopes($this->getRequestParameter('scope', $request, $this->defaultScope));
// Finalize the requested scopes
$finalizedScopes = $this->scopeRepository->finalizeScopes($scopes, $this->getIdentifier(), $client);
// Issue and persist access token
$accessToken = $this->issueAccessToken($accessTokenTTL, $client, null, $finalizedScopes);
// Send event to emitter
$this->getEmitter()->emit(new RequestAccessTokenEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request, $accessToken));
// Inject access token into response type
$responseType->setAccessToken($accessToken);
return $responseType;
}View on GitHub (pinned to 9d2f6fc0a0)