API authentication
There is one identity — a JWT for a real user — delivered two ways. Browsers receive it as httpOnly cookies and must send a CSRF header on mutations. Programmatic clients carry the same access token in an Authorization: Bearer header, which a browser can never auto-attach cross-site, so it is CSRF-exempt. This page shows the exact wire shape for each.
Bearer token (recommended for programmatic)
GET /api/v1/projects/{projectId}/certificates
Host: cap.example.com
Authorization: Bearer <access_token>
Accept: application/json
- Token: a JWT access token obtained from
POST /api/v1/auth/login(see below). Default TTL 15 minutes. - Authorization: the token's user's org role + project roles — there is no separate credential scope.
- CSRF: not required (no cookies ride the request).
Full workflow and CI/CD examples: Programmatic access.
curl
TOKEN=$(curl -fsS -X POST https://cap.example.com/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"ci-bot","password":"..."}' | jq -r .access_token)
curl -H "Authorization: Bearer $TOKEN" \
https://cap.example.com/api/v1/projects/$PID/certificates
HTTP client libraries
- Go
net/http:req.Header.Set("Authorization", "Bearer "+token). - Python
requests:headers={"Authorization": f"Bearer {token}"}. - TypeScript
ky:ky.create({ headers: { Authorization: `Bearer ${token}` }}).
Cookie (browser)
When a user logs in through the UI, the backend sets three cookies (login also returns the tokens in the body — the browser ignores those and relies on the cookies):
| Cookie | Path | Flags | Purpose |
|---|---|---|---|
access_token | / | HttpOnly, SameSite=Strict, Secure* | JWT access token (15 min default). |
refresh_token | /api/v1/auth | HttpOnly, SameSite=Strict, Secure* | Refresh token (7 days default). |
csrf_token | / | SameSite=Strict, Secure*, readable by JS | CSRF token to mirror into the header. |
* Secure is set when the server runs with TLS.
CSRF double-submit
Every cookie-authenticated mutation (POST/PUT/PATCH/DELETE) must echo the csrf_token cookie value in X-CSRF-Token:
POST /api/v1/projects/{projectId}/certificates
Cookie: access_token=...; csrf_token=XXXX
X-CSRF-Token: XXXX
Content-Type: application/json
{ ... }
The backend compares header vs cookie on every mutation. Mismatch → 403. GET requests never need CSRF. Bearer-authenticated requests never need CSRF — the middleware skips the check when the token arrived in the Authorization header.
Refresh flow
- Client hits an endpoint; access token expired →
401 expired_token. - Client calls
POST /api/v1/auth/refresh. The browser sends therefresh_tokencookie (and must includeX-CSRF-Token); a programmatic client sends{"refresh_token":"..."}in the body (CSRF-exempt, tokens returned in the body). - On success: new cookies set (browser) or new tokens returned (body client). Retry the original request.
- On failure (reuse detected or expired):
401. Redirect to login.
Reuse detection: presenting a refresh token that's already been consumed invalidates the whole token family — see Sessions.
Login endpoint
POST /api/v1/auth/login
Content-Type: application/json
{ "username": "alice", "password": "..." }
# → 200 OK (also sets access_token / refresh_token / csrf_token cookies)
{
"user": { "id": "...", "username": "alice", "org_role": "admin" },
"access_token": "<jwt>",
"refresh_token": "<jwt>",
"token_type": "Bearer",
"expires_in": 900
}
If the user has TOTP enrolled, login instead returns { "otp_required": true, "otp_session_id": "<id>" }; complete it with POST /api/v1/auth/otp/verify carrying the X-OTP-Session-ID header and {"code":"123456"}, which returns the same token body.
Logout
POST /api/v1/auth/logout
Revokes the current refresh token and clears all cookies. (Cookie sessions include X-CSRF-Token; a Bearer client can simply drop the token.)
Who am I
GET /api/v1/auth/me
# → current user + role + org / project memberships
GET /api/v1/auth/me/profile
# → profile preferences (timezone, language, notification prefs)
Both are read-only and accept either cookie or Bearer auth.
CORS
By default, no CORS — the UI and API share the same origin. Cross-origin browser apps need an explicit allowlist; set CERTAUTOPILOT_SERVER_CORS_ALLOWED_ORIGINS to a comma-separated list.
Auth error codes
| Status | error_type | Meaning |
|---|---|---|
| 401 | TOKEN_MISSING | No access token (cookie or Bearer header). |
| 401 | TOKEN_EXPIRED | Access token expired; refresh and retry. |
| 401 | TOKEN_INVALID | Signature / format wrong. |
| 401 | TOKEN_REUSE | Refresh token already used; family revoked. |
| 401 | mfa_required | Login needs OTP (complete the OTP session). |
| 403 | — | Authenticated but role too low (insufficient role). |
| 403 | — | Cookie mutation missing / wrong X-CSRF-Token. |
| 429 | — | Too many login / refresh / OTP attempts per IP. |
Troubleshooting
403 "CSRF" on a Bearer call
You're also sending cookies, so the middleware treats it as a cookie session. Drop the cookies — Bearer auth should be headers-only.
expired_token immediately after login
System clock skew > access token TTL (15 min). NTP the backend.
Browser CORS error from a test harness
Tests call from a different origin. Add it to CERTAUTOPILOT_SERVER_CORS_ALLOWED_ORIGINS, or proxy the test traffic through the same origin.