Credentials for programs and CI jobs
A program holds as little as it can, for as short as it can, and a copy is useless without its key.
In the adminProgram credentials
A bearer token works for whoever holds it. Quilzo gives programs three ways to hold less: a CI job exchanges its platform's identity token and keeps no secret at all; any program exchanges its token for one that lasts minutes; and a token can be bound to a key, so a copy without the key is useless.
CI jobs without a stored secret
GitHub Actions, GitLab CI and the clouds give each job a signed identity token naming the repository, the branch, the environment and the run, valid for minutes. Declare which jobs are trusted and what they get:
quilzo trust add docs-ci --github octo/docs --ref refs/heads/main \
--as ci:docs --role author --on /docs --for 15m
quilzo auth grant ci:docs author --on /docs
A trust names an issuer, the audience jobs ask for (this store's address by default, from admin.base_url), and the claims a token must carry: --github pins the repository, --gitlab the project, and --ref and --environment narrow further. A value ending in * matches a prefix, such as refs/heads/release/*. Any other issuer works with --issuer URL --claim NAME=VALUE. A trust always pins the workload exactly, never makes an administrator, and gives a token that lasts at most an hour.
In the job, ask the platform for an identity token with the store's address as its audience, and exchange it:
permissions:
id-token: write
steps:
- run: |
ID=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://admin.example" | jq -r .value)
TOKEN=$(curl -s https://admin.example/token/exchange \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
--data-urlencode subject_token="$ID" | jq -r .access_token)
Quilzo reads the issuer's signing keys from its discovery document, checks the signature, the issuer, the audience, the times and every pinned claim, and admits each identity token once. The audit log records the trust, the repository, the branch and the run.
Exchange a token for a shorter one
POST /token/exchange is RFC 8693 token exchange. With a Quilzo token as subject_token and subject_token_type=urn:ietf:params:oauth:token-type:access_token, it returns a session that lasts fifteen minutes, never past the original, narrowed by scope (a role) and resource (a path). Keep the long-lived token where the program starts, and give the work the short one. quilzo token exchange does the same on the command line.
Bind a token to a key
A token bound to a key is accepted only with a fresh proof made by that key (RFC 9449, DPoP): a short signed statement of the method and address of each request and a hash of the token. A copy of the token, from a log or a crash dump, does nothing without the key, and a copy of a proof is good for one request to one address for five minutes.
quilzo token keygen program.key # the key, kept with the program
quilzo token issue nightly --principal ci:docs --key program.key.pub
quilzo token proof --key program.key --url https://admin.example/mcp --token "$TOKEN"
Send the token as Authorization: DPoP with the proof in a DPoP header. A request to the exchange with a proof gets a session bound to that key. The agent interface at /mcp, the model gateway at /v1 and A2A at /a2a check proofs; every other surface, including the command line, refuses a bound token. Keep the key somewhere other than the token: a key stored beside its token is taken with it.
What needs a bound token
What a copied token is worth is what its holder could do with it, for as long as it lasts. So from another machine, through the agent interface, the MCP gateway and A2A, the acts that cannot be taken back need a token that is worthless to a thief:
| From another machine | Takes |
|---|---|
| Reading and drafting | Any token, including the bearer tokens the OpenAI and Anthropic SDKs and most MCP clients send |
| Publishing, rolling back, granting, managing tokens and agents, installing | A token bound to the program's key and proved on the request, or one that lasts an hour or less: an app's consented token, an exchanged session, a CI job's token |
A long-lived bearer token asked to publish is refused with the three ways forward: exchange it for a fifteen-minute session, connect as an app, or bind a key. quilzo token issue says so when it makes such a token. The rule is token.proof_for_risky; turning it off is a weakening an administrator accepts in writing. Browser sessions are bound to their device and step up when they move, and the command line runs on the store's own machine, so neither changes.
How long tokens last
| Token | Lasts |
|---|---|
Issued with quilzo token issue | A week by default (token.ttl.default), ninety days at most (token.ttl.max) |
| Exchanged for a session | Fifteen minutes, at most twelve hours, never past the original |
| Exchanged by a CI job | What the trust says, at most an hour |