Skip to main content

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}` }}).

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):

CookiePathFlagsPurpose
access_token/HttpOnly, SameSite=Strict, Secure*JWT access token (15 min default).
refresh_token/api/v1/authHttpOnly, SameSite=Strict, Secure*Refresh token (7 days default).
csrf_token/SameSite=Strict, Secure*, readable by JSCSRF 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

  1. Client hits an endpoint; access token expired → 401 expired_token.
  2. Client calls POST /api/v1/auth/refresh. The browser sends the refresh_token cookie (and must include X-CSRF-Token); a programmatic client sends {"refresh_token":"..."} in the body (CSRF-exempt, tokens returned in the body).
  3. On success: new cookies set (browser) or new tokens returned (body client). Retry the original request.
  4. 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

Statuserror_typeMeaning
401TOKEN_MISSINGNo access token (cookie or Bearer header).
401TOKEN_EXPIREDAccess token expired; refresh and retry.
401TOKEN_INVALIDSignature / format wrong.
401TOKEN_REUSERefresh token already used; family revoked.
401mfa_requiredLogin needs OTP (complete the OTP session).
403Authenticated but role too low (insufficient role).
403Cookie mutation missing / wrong X-CSRF-Token.
429Too 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.

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.

See also