Authorization, for machines.
A self-hosted OAuth 2.0 server. Your service asks, Handsal signs a token for one audience, and the token expires. An agent waits when a person has to say yes.
- iss
- https://auth.example.com
/t/acme - aud
- https://api.acme.example
/orders - scope
- orders:read
- exp
- 3600 seconds
- sub
- app_k7mn2p4qr5s6tvwx2abc
- alg
- ES256
- kid
- k_2026-11-07_c4e1
What it is for.
billing / orders
A service calls another.
Billing needs orders:read and nothing beside it. The token names the orders API. A token minted for the warehouse is refused.
agent / refunds
An agent waits for a person.
The agent proves itself with a private key, so the secret never sits in a prompt. A read can be granted outright. A refund waits for a person, and the token lasts five minutes.
acme / globex
Each team has its own issuer.
One server, many tenants. Each has its own keys, applications, and audiences. A token from one tenant does not verify against another.
What a token is allowed to be.
One audience
The resource parameter is required. A token is issued for one API, with only the scopes that application was granted on it.
Keys stay with the tenant
Signing is ES256. The private key is encrypted at rest. Resource servers check the signature against that tenant's JWKS, or ask introspection.
You can take it back
Revoke one token, or every token for an application or a tenant. Introspection honors it at once. A server that only checks the signature waits until expiry, so the lifetime stays short. The default is one hour.
How a call works.
One container. PostgreSQL beside it.
Migrations run when the process starts. The admin UI is served from the same origin. You terminate TLS in a proxy and set the public URL to https.
docker compose -f docker-compose.prod.yml up -dThe server listens on port 3000.