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

  1. Send the client_secret with the request (body parameter or HTTP Basic auth) and confirm it matches the stored hashed secret
  2. Verify the client entity returned by your ClientRepository::getClientEntity has a non-empty secret and verifySecret passes, making isConfidential() true
  3. Check that the client record in your storage still exists and its secret hash matches (re-hash/re-save if the hashing algo changed)
  4. 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

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


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)