← Security

SECURITY FEATURE

Machine Auth

Documented bearer-token recipe. This page is reference material — the token shown below is a placeholder and is NOT validated by this handler. Real verification belongs to the semitexa/api package.

Feature Guide

A quick orientation block that answers the essential questions: what this feature does, how it works, why it matters, and the key concepts behind it.

What this does

Service-to-service authentication via Bearer tokens — issued from the CLI, scoped per route with #[RequiresScope], expiring, rotatable, revocable and audited.

Machine Identity

Bearer credentials for service-to-service traffic

Machine authentication is stateless, scope-aware, and easy to revoke without touching browser sessions.

# Reference format only — this demo page does not validate tokens.
# Real verification lives in the semitexa/api package.
curl -H "Authorization: Bearer demo-client-id:demo-secret-key-abc123" \
  https://your-app.com/api/products
PropertyValue
Status on this page Reference only — tokens are not verified here
Real verifier semitexa/api machine-auth pipeline
Format Bearer {client_id}:{secret}
Handler priority 50 (runs before session auth)
Scopes Stored on MachineCredential, checked per route
Revocation Set revoked_at for immediate effect
Audit Credential ID + timestamp logged per request
MachineAuthHandler Bearer {id}:{secret} MachineCredential scopes RequiresScope api:credential:issue revocation

Verified against Semitexa Ultimate 2026.10.20.0340

Machine Auth

Service-to-service authentication via Bearer tokens — scoped, expiring, revocable, and audited.

How it works

Clients send an Authorization: Bearer {credential_id}:{secret} header; the id is the credential's UUID, not its client name. MachineAuthHandler in semitexa/api reads that header from the live request, splits the token once into id and secret, and verifies it against the MachineCredential store without persisting the raw secret. Secret verification delegates to MachineCredential::verifySecret() (password_verify() under the hood), successful auth updates only usage audit fields such as lastUsedAt and requestCount, and the resolved MachinePrincipal exposes credential identity and scopes without echoing the secret back into application state.

Why this matters

Machine-to-machine auth needs a different shape than session auth: no redirect flow, no cookies, explicit scope boundaries, and an audit trail. A revoked credential takes effect immediately without restarting the server because the check happens at request time against the live credential record.

Issue a credential

bin/semitexa api:credential:issue erp-sync --tenant=acme --scope=orders:read --scope=orders:write --expires-in=90d

It prints the token once, as Authorization: Bearer {id}:{secret}; only an Argon2id hash of the secret is stored, so a lost token cannot be shown again, only replaced.

Option
--scope a scope it holds, repeatable. Free-form; the convention is resource:action. Matched by exact name, no wildcards
--tenant the only tenant it authenticates on; a single-tenant install uses default
--all-tenants valid on every tenant instead. One of the two is required, so an unset variable in a script cannot issue a credential valid everywhere
--expires-in 90d, 12h or 30m; after that it no longer authenticates. Without it, it never expires
--json the result, token included, as JSON for a script

In code, MachineCredentialIssuer::issue($client, $scopes, $tenantId, $expiresAt) does the same and returns the credential and its token.

Require scopes on a route

use Semitexa\Api\Attribute\RequiresScope;
use Semitexa\Authorization\Attribute\AsServicePayload;

#[AsServicePayload(path: '/api/orders', methods: ['POST'])]
#[RequiresScope('orders:write')]
final class CreateOrderPayload
{
    // ...
}

The caller must be a machine credential holding every scope named; otherwise 403, with the missing scope in the message. Without a token, or with a wrong, expired or revoked one, the route answers 401, as every service route does. In a handler, $principal->hasScope('orders:write') asks the same question.

#[RequiresScope] asks what the credential was issued with. #[RequiresCapability] on a service payload asks something else: the grants a ServiceCapabilityProviderInterface (semitexa/rbac) gives that service. Use scopes to limit what one token may do, capabilities when access is decided by policy in your own code.

Rotate, revoke, list

bin/semitexa api:credential:list                       # not revoked (expired ones marked): scopes, tenant, expiry, last use, requests
bin/semitexa api:credential:revoke <id> --rotate       # new secret, printed once; the old one stops working at once
bin/semitexa api:credential:revoke <id>                # revoked: refused from the next request

The id is the part of the token before the colon. A request that authenticates writes only the usage columns, so it cannot undo a revoke or rotation made while it ran. A revoked or expired credential is refused even with the right secret, and costs the caller the same time as a wrong secret, so the response time does not tell which ids exist.

Upgrading

Expiry added the expires_at column to api_machine_credentials. Run bin/semitexa orm:sync (or bin/semitexa update) before the new code serves requests: until the column exists, a machine request fails when its use is recorded. Existing credentials get no expiry.

© Harold Abelson: "Programs must be written for people to read, and only incidentally for machines to execute."

Handler Application entry point
<?phpdeclare(strict_types=1);namespace App\Application\Handler\Auth;use App\Application\Payload\Api\ProductListPayload;use App\Application\Resource\Api\ProductListResource;use App\Domain\Api\ProductApiReaderInterface;use Semitexa\Core\Attribute\AsPayloadHandler;use Semitexa\Core\Attribute\InjectAsReadonly;use Semitexa\Core\Contract\TypedHandlerInterface;#[AsPayloadHandler(payload: ProductListPayload::class, resource: ProductListResource::class)]final class MachineAuthHandler implements TypedHandlerInterface{    #[InjectAsReadonly]    protected ProductApiReaderInterface $reader;    public function handle(ProductListPayload $payload, ProductListResource $resource): ProductListResource    {        return $resource->fromProducts($this->reader->listForApi());    }}

Support Semitexa
Built for developers who prefer control over magic. Your support helps keep it fast, open, and evolving.

Donate via PayPal