Skip to main content

Programmatic access

There is no separate API-key credential. Scripts, CI/CD pipelines, and service accounts authenticate as a regular user: log in once for a short-lived JWT access token, send it as Authorization: Bearer <token>, and refresh it when it expires. A header-authenticated request carries no cookies, so it is immune to CSRF — no X-CSRF-Token needed.

The programmatic caller has exactly the org role and project roles of the user it logs in as. To scope automation tightly, create a dedicated user with the minimal role (e.g. an operator scoped to one project) — see Auth & RBAC.

Log in for a token

POST /api/v1/auth/login with a username and password:

curl -fsS -X POST https://cap.example.com/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"ci-bot","password":"..."}'
{
"user": { "id": "...", "username": "ci-bot", "org_role": "operator" },
"access_token": "<jwt>",
"refresh_token": "<jwt>",
"token_type": "Bearer",
"expires_in": 900
}
  • access_token — send as Authorization: Bearer. Default TTL 15 minutes (expires_in, seconds).
  • refresh_token — exchange for a new pair when the access token expires. Default TTL 7 days.
  • The same call also sets browser cookies; programmatic clients ignore them and use the body tokens.
MFA-enrolled users

If the user has TOTP enabled, login returns { "otp_required": true, "otp_session_id": "<id>" } instead of tokens. Complete it with:

curl -fsS -X POST https://cap.example.com/api/v1/auth/otp/verify \
-H 'Content-Type: application/json' \
-H "X-OTP-Session-ID: <id>" \
-d '{"code":"123456"}'

which returns the same token body. Service accounts that run unattended should be created without MFA.

Use the token

export CAP_TOKEN=<access_token>
curl -fsS -H "Authorization: Bearer $CAP_TOKEN" \
"https://cap.example.com/api/v1/projects/$PROJECT_ID/certificates"

CI/CD example (GitHub Actions) — log in, then call the API with the returned token:

- name: List expiring certificates
env:
CAP_USER: ${{ secrets.CERTAUTOPILOT_USERNAME }}
CAP_PASS: ${{ secrets.CERTAUTOPILOT_PASSWORD }}
run: |
TOKEN=$(curl -fsS -X POST "$CAP_URL/api/v1/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$CAP_USER\",\"password\":\"$CAP_PASS\"}" \
| jq -r .access_token)
curl -fsS -H "Authorization: Bearer $TOKEN" \
"$CAP_URL/api/v1/projects/$PROJECT_ID/certificates?expiring_days=30"
  • Header-authenticated requests bypass CSRF — no browser cookies are involved, so mutations need only the Authorization header.
  • See API authentication for the full wire shape and client snippets, and the API reference for endpoints.

Refresh the token

When the access token expires (a call returns 401 expired_token), exchange the refresh token for a new pair. Pass it in the request body — a body refresh is not a cookie-riding request, so it is CSRF-exempt:

curl -fsS -X POST https://cap.example.com/api/v1/auth/refresh \
-H 'Content-Type: application/json' \
-d "{\"refresh_token\":\"$REFRESH\"}"
{ "access_token": "<jwt>", "refresh_token": "<jwt>", "token_type": "Bearer", "expires_in": 900 }

Reuse detection: each refresh token is single-use. Presenting one that has already been exchanged revokes the entire token family (login again to recover) — this contains a stolen refresh token. See Sessions.

Long-running automation should keep the latest refresh_token from every response and rotate through it; short jobs can simply log in at the start and let the access token last the run.

Scoping automation

Because a programmatic caller is a user, its reach is its user's role and project memberships — there is no separate key scope. Recommended patterns:

  • Least privilege — a dedicated user with operator (issue/renew/distribute) or viewer (read-only) rather than admin.
  • Per-project isolation — a project-scoped role on a single project; org-wide endpoints then return 403 insufficient_role.
  • Rotation — change the service account's password (and re-login) to invalidate outstanding refresh tokens, or disable the user to cut off access immediately.

Rate limiting

login, otp/verify, and refresh are each limited to 10 attempts per client IP per minute; further attempts get 429. The limit is per pod (cluster total ≈ limit × replica count).

Behind a reverse proxy

The limiter keys on the client IP as seen by Gin (X-Forwarded-For aware). Configure trusted proxies on the server when running behind a load balancer or ingress, or all attempts will appear to come from one IP.

Audit

Programmatic requests are audited under the acting user — the actor renders as the user's username/email, exactly like a UI session. There is no separate key identity to correlate; filter the audit log by the service account's user.

Troubleshooting

401 expired_token mid-run

The 15-minute access token lapsed. Call POST /api/v1/auth/refresh with the stored refresh_token and retry with the new access token.

401 invalid_token right after login

Header hygiene: Authorization: Bearer <token> with a single space and no stray newline. Confirm you're sending the access_token, not the refresh_token.

401 token_reuse on refresh

The refresh token was already used (a duplicate/retried refresh, or a leaked token used elsewhere). The token family is revoked — log in again to obtain a fresh pair.

403 insufficient_role on an org endpoint

Working as designed — the service account's user lacks the org role (or is project-scoped). Grant the needed role, or use an org-level user for org-wide automation.

See also