This guide covers the OAuth 2.0 Client Credentials flow: a backend service authenticates as itself (no end user) to call another backend service.
Working examples:
examples/service-token.php(one-shot)examples/service-account-cached.php(with token caching)
- Backend → backend API calls.
- Scheduled jobs / cron / Lambda triggers.
- CI/CD pipelines that need to call internal APIs.
- Anything where the identity is a service, not a human.
For user-facing logins, use Authorization Code instead.
Register a service account in the admin UI or via CLI:
composer client:create -- \
--project=deploy \
--name=ci-bot \
--service-account \
--role=deploy.admin \
--role=deploy.viewerThe server emits client_id (starts with svc_…) and client_secret once.
Store the secret in your secret manager — never in source control.
The --role flags pre-assign roles that will appear in the JWT's roles
claim. Service accounts don't have groups (groups are per-user).
use Stromcom\AuthClient\Client;
use Stromcom\AuthClient\Configuration;
$auth = new Client(new Configuration(
clientId: 'svc_xxxxxxxxxxxxxxxx',
clientSecret: getenv('AUTH_CLIENT_SECRET'),
));
$tokens = $auth->clientCredentials();
$response = $httpClient->get('https://api.stromcom.cz/v1/deployments', [
'headers' => ['Authorization' => $tokens->authorizationHeader()],
]);clientCredentials() returns a TokenSet:
$tokens->accessToken— JWT, valid for 1 hour by default.$tokens->refreshToken—null. Service accounts don't get refresh tokens; just request a new access token when needed.$tokens->expiresAt,$tokens->expiresIn— TTL info.
A worker that runs for hours and calls a downstream API on every iteration shouldn't refetch a token every time. Cache it:
class ServiceTokenCache {
private ?TokenSet $current = null;
public function __construct(
private readonly Client $auth,
private readonly int $refreshLeewaySeconds = 60,
) {}
public function get(): TokenSet {
$now = time();
if ($this->current === null || $this->current->isExpired($now, $this->refreshLeewaySeconds)) {
$this->current = $this->auth->clientCredentials();
}
return $this->current;
}
}isExpired($now, 60) returns true when the token has less than 60 seconds
of life left, so you refresh proactively before downstream calls start
failing with 401.
For multi-process deployments (PHP-FPM, Lambda concurrency), use a shared
cache backend instead (Redis, APCu, …). See
examples/service-account-cached.php
for a working pattern.
A client may request a subset of its allowed scopes:
$tokens = $auth->clientCredentials(scopes: ['deploy.write']);The returned scopes (in $tokens->scope and the JWT scopes claim) are the
intersection of what you requested and what the client is allowed.
Requesting an unknown scope returns an error from the server.
Note: roles and groups in the JWT are not filtered by scope for service tokens.
rolesandis_adminare always present (taken from the client record in the DB).groupsare never present for service tokens. See jwt-verification.md for the full breakdown.
The downstream service uses examples/verify-token.php
or its own variant to verify the JWT. Typical pattern on the consumer side:
$claims = $auth->verify($jwt);
$claims->requireServiceToken();
$claims->requireRole('deploy.admin');Identifying which service called you:
$serviceClientId = $claims->clientId; // e.g. "svc_xxxx"
$serviceClientName = $claims->clientName; // human-readable name, e.g. "ci-bot"Useful for audit logs.
When you rotate the client secret:
- In the admin UI, click Reset secret on the client — the server generates a new secret and shows it once.
- Store the new secret in your secret manager.
- Deploy / restart your service. There is no overlap window — the old secret stops working the moment the new one is generated.
For zero-downtime rotation, create a second service account with the same roles, switch to it, then deactivate the old one.
There is no token revocation endpoint. To invalidate active service tokens:
- Revoke the client in the admin UI — new token requests will fail.
- Active tokens stay valid until their
exp(default 1 hour).
Plan ahead by keeping access token TTLs short for sensitive service accounts.
| Symptom | Cause |
|---|---|
OAuthServerException: invalid_client |
Wrong client_id/client_secret, or the client has been revoked |
OAuthServerException: unauthorized_client |
Client doesn't have the client_credentials grant enabled |
OAuthServerException: invalid_scope |
Requested scope not allowed for this client |
TokenVerificationException: token_use missing |
Server is on an old version that doesn't emit token_use — upgrade the server |
Sporadic 401 from downstream API |
Token expired mid-request — increase refreshLeewaySeconds in your cache |