Delegated Credentials
A delegated credential is an access token that hands somebody a bounded piece
of your access — and cannot be widened by them.
Use it when the work is shared: a contractor running tests in one project, a CI
job that must not be able to touch staging, an MCP client an assistant connects
with, a partner integration you do not fully trust. An API token is the wrong
tool for that, because its owner can change what it is allowed to do. A delegated
credential cannot be changed at all after it is issued — not by its holder, and
not by you either. Re-issue it instead.
The endpoints live on the admin node, under POST /api/v1/auth/delegated-credentials.
The bearer carries the mkd_ prefix, which is how every part of Mockarty tells a
delegated credential from an API token (mk_) or an integration token (mki_)
before it looks anything up.
How it differs from an API token
| API token | Delegated credential | |
|---|---|---|
| Namespace | a default the holder can change | a set frozen at issue time; the holder cannot add to it |
| Actions | a list the holder can edit | frozen at issue time |
| Expiry | optional | required, and at most 365 days ahead |
| Who can change the scope | its owner | nobody — there is no endpoint that writes it |
| What it manages | its owner’s tokens | only the delegated credentials it issued itself |
| Bearer prefix | mk_ |
mkd_ |
The practical difference: an API token given to a contractor can be widened by
that contractor into every namespace with permission to delete. A delegated
credential given to the same contractor stays in the namespaces you named, with
the actions you named, until the moment you named — or until you revoke it.
The frozen scope
Three properties are fixed when the credential is issued:
| Property | What it means | What the holder can do about it |
|---|---|---|
| Namespaces | the namespaces the credential may act in | nothing. A request naming a namespace outside the set is refused, not silently redirected |
| Actions | read, write, delete |
nothing. The action list is written from the credential itself on every request |
| Expiry | the instant it stops working | nothing. It is required, and at most 365 days ahead |
A namespace named in a header, a query parameter or a JSON body cannot move the
boundary. The namespace a request may act in comes from the stored credential;
what you send can only select one of the namespaces that were frozen, never add
one.
Issuing one
The issuer is whoever is authenticated: a person signed into the cabinet, or a
service holding an API token. You may only issue a credential whose scope is
inside your own access — and if the caller is itself a delegated credential, only
inside its own frozen scope. A chain of delegations can therefore only ever get
narrower, never wider.
CLI
mockarty-cli delegated-credential create \
--name ci-reader \
--description "nightly regression job" \
--namespaces staging \
--actions read \
--expires 30d
--namespaces is plural because it is the set the credential is frozen to. The
global --namespace flag is a different thing: the namespace your CLI session
works in.
cURL
curl -X POST http://localhost:5770/api/v1/auth/delegated-credentials \
-H "Authorization: Bearer mk_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "ci-reader",
"description": "nightly regression job",
"namespaces": ["staging"],
"allowed_actions": ["read"],
"expires_at": "2026-12-31T00:00:00Z"
}'
Go
expires := time.Now().UTC().Add(30 * 24 * time.Hour)
issued, err := client.DelegatedCredentials().Create(ctx, mockarty.DelegatedCredentialCreateRequest{
Name: "ci-reader",
Description: "nightly regression job",
Namespaces: []string{"staging"},
AllowedActions: []string{"read"},
ExpiresAt: &expires,
})
fmt.Println(issued.Token) // shown once
Python
from datetime import datetime, timedelta, timezone
from mockarty import DelegatedCredentialCreateRequest
issued = client.delegated_credentials.create(DelegatedCredentialCreateRequest(
name="ci-reader",
description="nightly regression job",
namespaces=["staging"],
allowed_actions=["read"],
expires_at=datetime.now(timezone.utc) + timedelta(days=30),
))
print(issued.token) # shown once
Java
DelegatedCredentialWithToken issued = client.delegatedCredentials().create(
DelegatedCredentialCreateRequest.builder()
.name("ci-reader")
.description("nightly regression job")
.namespaces(List.of("staging"))
.allowedActions(List.of("read"))
.expiresAt(Instant.now().plus(Duration.ofDays(30)))
.build());
System.out.println(issued.getToken()); // shown once
The response contains the plain bearer once. It is not stored and cannot be
read back — only a verifier of it is kept — so save it where the integration can
reach it. If you lose it, rotate the credential rather than issuing a new one, so
the audit trail stays on one identity.
Omit allowed_actions to grant the full vocabulary (read, write, delete).
The namespace set and the expiry are still required: without them the credential
would be an API token, which is the thing this exists to replace.
Using one
The holder presents it exactly as it would present an API token:
MOCKARTY_API_KEY=mkd_... mockarty-cli mock list
client := mockarty.NewClient("http://localhost:5770",
mockarty.WithAPIKey("mkd_..."))
For a project integration, store the bearer as a secret in that project and read
it into the same variable an API token would go in — no client-side changes are
needed, because the scope is applied by the server.
An attempt to act outside the frozen scope is refused with 403, naming the
credential rather than the caller, so an operator can tell a scope refusal from a
permission refusal.
Rotating
Rotation issues a fresh bearer and leaves the scope exactly as it was. The
previous bearer stops working immediately. Reach for it when a token leaked but
the work must continue:
CLI
mockarty-cli delegated-credential rotate 5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51
cURL
curl -X POST http://localhost:5770/api/v1/auth/delegated-credentials/5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51/rotate \
-H "Authorization: Bearer mk_YOUR_TOKEN"
Go
rotated, err := client.DelegatedCredentials().Rotate(ctx, "5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51")
fmt.Println(rotated.Token)
Python
rotated = client.delegated_credentials.rotate("5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51")
print(rotated.token)
Java
DelegatedCredentialWithToken rotated =
client.delegatedCredentials().rotate("5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51");
System.out.println(rotated.getToken());
Rotation is not a scope change. If the work now needs a different namespace, a
different action or a later expiry, issue a new credential: the change then
has its own identity in the audit trail instead of being a silent edit on a
credential someone else is already holding.
Revoking
Revocation is permanent and takes effect across every node of a cluster, not just
the one that answered the request. There is deliberately no un-revoke — “temporarily
revoked” is exactly the window a shared credential should not have. Revoking twice
is a no-op rather than an error.
CLI
mockarty-cli delegated-credential revoke 5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51
cURL
curl -X DELETE http://localhost:5770/api/v1/auth/delegated-credentials/5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51 \
-H "Authorization: Bearer mk_YOUR_TOKEN"
Go
err := client.DelegatedCredentials().Revoke(ctx, "5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51")
Python
client.delegated_credentials.revoke("5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51")
Java
client.delegatedCredentials().revoke("5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51");
Listing them
An issuer sees every credential it issued, newest first. A delegated credential
sees only the credentials it issued itself — never its issuer’s, and never a
sibling’s.
CLI
mockarty-cli delegated-credential list
cURL
curl http://localhost:5770/api/v1/auth/delegated-credentials \
-H "Authorization: Bearer mk_YOUR_TOKEN"
Go
page, err := client.DelegatedCredentials().List(ctx)
for _, cred := range page.Credentials {
fmt.Println(cred.ID, cred.Name, cred.Namespaces, cred.ExpiresAt)
}
Python
for cred in client.delegated_credentials.list().credentials:
print(cred.id, cred.name, cred.namespaces, cred.expires_at)
Java
for (DelegatedCredential cred : client.delegatedCredentials().list().getCredentials()) {
System.out.println(cred.getId() + " " + cred.getNamespaces());
}
What a delegated credential deliberately cannot do
- It cannot be widened. No endpoint writes the namespaces, the actions or the
expiry. Rotation replaces the bearer and nothing else. - It cannot manage the credential owner’s API tokens. Creating, reading,
editing and revoking API tokens are all refused, because it acts as its issuer
there and could otherwise mint itself an unrestricted token. - It cannot revoke or rotate its issuer. It manages only the credentials it
issued itself, so it cannot destroy or replace the credential it depends on. - It cannot revoke or rotate a sibling issued alongside it.
- It cannot outlive a year. An expiry more than 365 days ahead is refused.
- It cannot be un-revoked. Revocation is terminal.
- It cannot be selected for by a header. A namespace in a header, query
parameter or body can only pick one of the namespaces that were frozen. - It is not usable over gRPC. The gRPC surface authenticates API tokens only,
so a delegated bearer is refused there — an honoured boundary this primitive has
not been extended to. - It cannot be issued from an MCP session. Issuing a credential is an operator
action with a human behind it, so the five endpoints are not part of the tool
catalogue an assistant browses — a tool that mints credentials hands the caller
the choice of scope. Use the cabinet, the CLI or an SDK. - It inherits no rate limit of its own. A delegated credential is not counted
against a per-token request budget; the limits that apply are the ones in force
for the issuer’s account. Apply a limit at the edge if the integration needs one.
Also worth knowing:
- A credential whose issuer’s account is disabled stops working, even before its
expiry. The issuer must still be an active user for the credential to be accepted. - A revocation is broadcast to every node of a cluster. A node that misses the
broadcast stops accepting the credential within about a minute, so revoke first
and treat a brief overlap as expected rather than as a failure to revoke. - The plain bearer is never written to an audit record or a log. Audit records
carry the display label — the first characters of the token — which is enough to
match a listing row to a reported leak.
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/auth/delegated-credentials |
Issue a credential; returns the bearer once |
| GET | /api/v1/auth/delegated-credentials |
List the credentials this caller may manage |
| GET | /api/v1/auth/delegated-credentials/:id |
Read one credential |
| POST | /api/v1/auth/delegated-credentials/:id/rotate |
Replace the bearer, keep the scope |
| DELETE | /api/v1/auth/delegated-credentials/:id |
Revoke permanently |
A credential outside the caller’s authority answers 404, not 403 — otherwise
a delegated credential could discover its issuer’s credentials one id at a time.
Issue, rotate and revoke are recorded in the audit trail against the credential’s
own id, so an incident review can pull one credential’s whole history with a single
filter.
Where to manage them in the cabinet
Open API Tokens in the cabinet. A namespace owner sees a Delegated
credentials panel there, listing what they have issued with its frozen scope,
its state and its last use, and offering issue, rotate and revoke.
Related
- API Reference — the authentication endpoint table
- CLI User Guide — contexts and credentials for the CLI
- Administration Guide — users, roles and namespaces