Federated Enrollment — configuration & recipes
Federated enrollment lets agents obtain identities without any per-agent
secret: the agent self-generates its key, and something apd already trusts —
your Kubernetes cluster, your operator, your CI provider, your corporate CA —
vouches for it with a signed assertion. apd verifies the assertion and
issues the identity. This page is the operator how-to; the model and security
rationale are in federated-enrollment-design.md,
and the basics of enrollment in general are in enrollment.md.
sequenceDiagram
autonumber
participant TA as Trust anchor<br/>(k8s / operator / CA / CI)
participant AG as Agent (pod / job)
participant AP as apd
AG->>AG: generate durable key
AG->>TA: request evidence<br/>(SA token / operator JWT / cert)
TA-->>AG: signed assertion (JWS)
AG->>AP: POST /enroll — signed with the key,<br/>+ enrollment_assertion
Note over AP: verify vs configured trusted issuer:<br/>signature, aud, exp, claims, SANs, cnf binding
AP-->>AG: 201 identity aauth:local@your-domain
Note over AG,AP: no per-agent secret ever existed
1. Enabling it
"enrollment": {
"methods": ["token", "federated", "allowlist"],
"trusted_issuers": [ ... ]
}
methods— the enabled gates, evaluated per request as: presented assertion → presented enrollment token → allowlist → open. A presented-but-invalid credential is a hard 403 (no fall-through).- The legacy
"mode": "token" | "open"form still works. apd example-config --federatedprints a full starting point.
The agent’s side is one extra body field on the normal enrollment request:
POST /enroll (signed with the durable key, hwk scheme)
{ "enrollment_assertion": "<JWS/JWT>" }
2. Trusted issuer reference
Each trusted_issuers[] entry:
| Field | Applies to | Meaning |
|---|---|---|
name |
all | unique operator-chosen name (appears in audit + agent records) |
type |
all | oidc | jwks_uri | jwks_file | jwks | x5c |
issuer |
all | exact iss value assertions must carry; routes the assertion to this entry |
audience |
all | required aud (string or array member). Default: apd’s issuer URL |
jwks_uri |
jwks_uri, oidc (override) |
direct JWKS URL |
jwks_file / jwks |
jwks_file / jwks |
JWKS from disk / inline (loaded at startup, fail-fast) |
ca_bundle_file |
x5c |
PEM bundle of trusted CA roots |
crl_file |
x5c |
optional CRL(s) (PEM or DER) — enables revocation checking |
required_sans |
x5c |
leaf-cert DNS/URI SAN patterns (exact or trailing-*), any-of |
required_claims |
all | claim-path → matcher (exact, array-of-allowed, or trailing-* prefix) |
embed_claims |
all | assertion-claim path → agent-token claim name; stamped into every token issued for the enrollment (and inherited by its sub-agents) |
require_cnf_binding |
all | assertion must bind the enrolling key via cnf.jwk/cnf.jkt |
single_use_jti |
all | enforce single-use jti. Default: true when not cnf-bound, false when bound |
ps |
all | pin the Person Server for these enrollments |
label |
all | label recorded on the enrollment |
allow_insecure_egress |
oidc, jwks_uri |
allow http / private-network fetches for THIS issuer (on-prem). Explicit opt-out of SSRF hardening |
Assertion requirements (uniform): iss matching an entry; aud containing
the entry’s audience; exp in the future (iat/nbf sane, 60 s skew); signed
with EdDSA, RS256, RS384, RS512, ES256, or ES384. Claim paths handle dotted
key names (kubernetes.io.namespace resolves the kubernetes.io claim).
Key freshness: remote JWKS (oidc/jwks_uri) are cached with a once-per-minute
per-issuer refresh floor and a 24 h ceiling, refreshed on unknown kid.
3. Recipes
3.1 EKS / GKE / AKS — Kubernetes projected ServiceAccount tokens
Managed clusters expose a public OIDC issuer, so apd verifies pod identity tokens with zero secrets anywhere.
Pod spec — project a token with apd as the audience:
volumes:
- name: apd-token
projected:
sources:
- serviceAccountToken:
path: token
audience: https://ap.example.com
expirationSeconds: 600
apd config:
{
"name": "prod-eks",
"type": "oidc",
"issuer": "https://oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLE",
"required_claims": { "sub": "system:serviceaccount:agents:*" },
"embed_claims": { "kubernetes.io.namespace": "k8s_namespace" },
"label": "eks-prod"
}
Agent startup: read /var/run/.../token, generate a key, POST /enroll with
the token as enrollment_assertion. Notes: the issuer URL is kubectl get
--raw /.well-known/openid-configuration | jq -r .issuer (GKE/AKS analogous);
sub for a ServiceAccount is system:serviceaccount:<ns>:<sa>; SA tokens have
no cnf, so single-use jti applies by default — a stolen token is dead after
first use and theft becomes visible as a failed legitimate enrollment.
3.2 On-prem / air-gapped Kubernetes — jwks_file
When the API server’s issuer isn’t reachable from apd (private CA, no route), export the cluster’s JWKS and mount it:
kubectl get --raw /openid/v1/jwks > /etc/apd/k8s-jwks.json
{
"name": "onprem-k8s",
"type": "jwks_file",
"issuer": "https://kubernetes.default.svc.cluster.local",
"jwks_file": "/etc/apd/k8s-jwks.json",
"required_claims": { "sub": "system:serviceaccount:agents:*" }
}
Re-export on API-server key rotation (mount via ConfigMap and restart, or a
small sync job). Alternative if apd can reach the API server but it uses http
or a private address: type: oidc + allow_insecure_egress: true.
3.3 Custom operator / orchestrator — cnf-bound assertions (strongest)
Your operator (k8s operator, Nomad driver, fleet manager) holds a signing key and mints a per-agent, key-bound assertion at spawn time. This is the strongest option: a stolen assertion is useless with any other key.
Operator (pseudocode):
on_spawn(agent):
agent generates durable key; sends thumbprint (jkt) to operator over IPC
assertion = JWS sign with OPERATOR_KEY {
iss: "https://operator.internal", aud: "https://ap.example.com",
sub: agent.pod_name, tenant: agent.tenant,
cnf: { jkt: agent.jkt }, # binds the agent's key
iat: now, exp: now+300, jti: random
}
hand assertion to agent → agent enrolls with it
apd config (mount the operator’s public JWKS via ConfigMap/secret):
{
"name": "agent-operator",
"type": "jwks_file",
"issuer": "https://operator.internal",
"jwks_file": "/etc/apd/operator-jwks.json",
"require_cnf_binding": true,
"embed_claims": { "tenant": "tenant" },
"ps": "https://ps.example"
}
The operator’s key belongs in KMS/HSM; rotate by publishing both kids during
the overlap. If the operator hosts its JWKS over https, use jwks_uri instead
of a file.
3.4 SPIFFE / SPIRE
JWT-SVIDs (simplest): agents fetch a JWT-SVID with aud = apd from the
Workload API. Verify against the trust-domain bundle:
- If you run the SPIRE OIDC discovery provider:
type: oidc,issuer= its URL. - Otherwise export the JWKS bundle to a file:
type: jwks_file, andrequired_claims: { "sub": "spiffe://corp.example/ns/agents/*" }.
X.509-SVIDs: use the x5c recipe below with the SPIRE trust-bundle CA and
required_sans: ["spiffe://corp.example/ns/agents/*"] — the SPIFFE ID lives in
the certificate’s URI SAN, which apd extracts and matches.
3.5 Corporate PKI (step-ca, AD CS, Vault PKI) — x5c
Any workload holding a client certificate from your CA can enroll by signing a
short JWS with its certificate key and attaching the chain in the JOSE x5c
header:
Assertion (built by the workload):
// header
{ "alg": "ES256", "x5c": ["<leaf DER b64>", "<intermediate DER b64>"] }
// payload
{ "iss": "https://ca.corp.example", // must equal the entry's issuer
"aud": "https://ap.example.com",
"cnf": { "jkt": "<thumbprint of the agent's durable key>" },
"iat": ..., "exp": "<= +5 min" }
apd config:
{
"name": "corp-ca",
"type": "x5c",
"issuer": "https://ca.corp.example",
"ca_bundle_file": "/etc/apd/corp-roots.pem",
"crl_file": "/etc/apd/corp.crl",
"required_sans": ["spiffe://corp.example/ns/agents/*", "*.agents.corp.example"],
"require_cnf_binding": true
}
apd validates the chain to your roots (client-auth usage, expiry, optional
CRLs), verifies the JWS with the leaf key (RSA/ECDSA/Ed25519 leaves all
work), enforces the SAN policy, and binds the agent key via cnf. When the CA
directly certifies the agent’s Ed25519 durable key, the same key signs both the
assertion and the enrollment — the tightest possible chain.
3.6 CI-spawned agents — GitHub Actions / GitLab OIDC
{
"name": "github-actions",
"type": "oidc",
"issuer": "https://token.actions.githubusercontent.com",
"audience": "https://ap.example.com",
"required_claims": {
"repository": "your-org/agents",
"ref": "refs/heads/main"
},
"embed_claims": { "repository": "ci_repository" },
"label": "ci"
}
Workflow side: request the ID token with audience: https://ap.example.com
(permissions: id-token: write), generate a key, enroll with the token as the
assertion. GitLab: issuer = your GitLab URL, claims like project_path.
3.7 Thumbprint allow-list — API-driven pre-registration
For orchestrators that prefer calling an API over signing assertions:
# orchestrator, after provisioning the agent's key:
curl -X POST https://ap.example.com/admin/allowed-keys \
-H "Authorization: Bearer $APD_ADMIN_TOKEN" \
-d '{"jkt": "<durable-key thumbprint>", "ps": "https://ps.example",
"label": "batch-42", "ttl": 600}'
# the agent then enrolls with only its key — no secret ever travels to it
Enable with "methods": [..., "allowlist"]. Registrations are consumed on
first use (re-enrollment of the same key stays idempotent); GET
/admin/allowed-keys lists pending ones, DELETE /admin/allowed-keys/{jkt}
withdraws. The “credential” is the public key’s hash, registered over the
authenticated admin channel.
4. Claims in issued tokens (embed_claims)
Matched assertion claims are persisted on the enrollment and stamped into
every agent token apd issues for that agent (sub-agents inherit them). This
is how k8s_namespace, tenant, ci_repository, group become gateable at
your MCP servers, gateways, and Person Servers — they’re AP-attested claims,
and AAuth receivers ignore claims they don’t recognize. Target names must be
lowercase [a-z0-9_] and non-reserved (validated at startup).
5. Audit
Every enrollment decision is emitted as a structured JSON line (stderr, plus
audit_log_file if set): enroll (method, issuer, subject, jkt, ps),
enroll_denied (reason), agent_token_issued, allowed_key_added/removed,
agent_revoked, … With no human in the issuance loop, this stream is your
review trail — ship it to your log pipeline.
6. Security checklist
- Scope
required_claimstightly (namespace/SA/repo — never trust a bare issuer). - Prefer
require_cnf_binding: truewherever you control the assertion minting. - Leave
single_use_jtidefaults alone unless you have a reason. audienceshould be apd’s exact issuer URL (the default).- Operator/CA keys in KMS/HSM; rotate with kid overlap.
allow_insecure_egressonly for on-prem issuers you operate, per-issuer.- x5c: keep the CA bundle minimal (your issuing CA, not a public root store);
use
required_sans; wirecrl_fileif you revoke certs. - Ship the audit log.
7. Troubleshooting
Symptom (403 invalid_assertion detail) |
Likely cause |
|---|---|
issuer '…' is not trusted |
assertion iss ≠ any entry’s issuer (exact match, incl. scheme + no trailing slash) |
aud does not include … |
projected token minted without audience: apd’s URL |
signature verification failed |
wrong JWKS (rotated keys? re-export jwks_file), or kid mismatch |
fetch floor active |
JWKS refetch rate-limited (once/min) — retry shortly |
claim '…' does not satisfy policy |
required_claims matcher vs actual value (check wildcard) |
cnf.jkt does not match |
assertion bound to a different key than the one signing the enroll |
assertion has already been used |
single-use jti replay (or a retry after a successful enroll from a different key) |
certificate chain validation failed |
leaf not chained to ca_bundle_file, expired, or revoked |
SANs do not satisfy required_sans |
leaf SAN vs pattern (URIs must match scheme too) |
| OIDC discovery fails to a private IP | expected SSRF block — use jwks_file or per-issuer allow_insecure_egress |