API Access
This guide covers the OAuth 2.1 client-credentials flow that credential service accounts use to authenticate API calls. For the conceptual overview and the UI walkthrough see Service Accounts.
Prerequisites
Section titled “Prerequisites”- A credential service account with at least one role assigned. Create one at Settings > Team — see Service Accounts > Creating a credential SA.
- The SA’s
client_id(arm_sa_…) andclient_secret(arm_sk_…). The secret is shown once at creation; if you’ve lost it, delete the SA and create a new one.
The examples below use https://api.adversarial.com as the base URL.
Exchange credentials for a token
Section titled “Exchange credentials for a token”Send a POST to /api/v1/oauth/token with grant_type=client_credentials. The body is form-encoded (RFC 6749 §4.4):
curl -s -X POST https://api.adversarial.com/api/v1/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "client_id=arm_sa_pFDbbNBKzenPiJm7vmqJwZqWRB77hqeI" \ -d "client_secret=arm_sk_2siXAtBbJi3UWwPPqKm2XEmiLUPowY2LCGJ8khTaahfus2XTqHEL8NmDvpxtmV8u"The response is a standard OAuth token pair:
{ "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6...", "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6...", "token_type": "Bearer", "expires_in": 900}access_token— short-lived (15 minutes). Use it as a bearer token on every API call.refresh_token— long-lived (7 days). Exchange for a new access token without re-sending the secret.expires_in— seconds until the access token expires.
Call the API
Section titled “Call the API”Send the access token as a bearer:
curl -s https://api.adversarial.com/api/v1/users/me \ -H "Authorization: Bearer $ACCESS_TOKEN"{ "id": "8110c7c3-704c-42ec-b0f9-7315a031e5f8", "first_name": "risk-sync-agent", "last_name": "", "email": "risk-sync-agent-yvnbi6@svc.adversarial.com", "icon": null}You’re now acting as the SA. Every API call attributes back to it in the Service Accounts table on Settings > Team — risks created via POST /api/v1/risks are Opened By: risk-sync-agent, comments via POST /api/v1/risks/{id}/comments show the SA as the author, and so on.
Refreshing the token
Section titled “Refreshing the token”When the access token is close to expiry (or after you get a 401), exchange the refresh token:
curl -s -X POST https://api.adversarial.com/api/v1/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=refresh_token" \ -d "refresh_token=$REFRESH_TOKEN"You get a fresh access + refresh pair. The new access token’s scope is carried forward from the original — refreshing cannot escalate permissions.
How permissions are enforced
Section titled “How permissions are enforced”The platform checks an SA’s permissions at two points:
- When the token is issued. The token carries the SA’s permissions at the moment of issuance. A leaked token can never exceed those permissions.
- On every API call. The platform re-reads the SA’s current permissions and applies the more restrictive set. If you remove a role from the SA, every existing token’s effective permissions shrink immediately — there is no caching delay.
The combined rule: a token never grants more than the intersection of (a) what the SA had when the token was issued and (b) what the SA has right now. Promoting an SA after a token is issued does not expand that token’s powers — request a new token to pick up the new permissions.
IP allowlists
Section titled “IP allowlists”If the SA was created with an allowed_ips list, the platform rejects authenticated API calls coming from any other source IP. The check runs on every API request; the token endpoint itself does not IP-filter, so the allowlist effectively kicks in the moment the token is used.
# SA's allowed_ips = ["10.5.5.5/32"], request comes from 1.2.3.4$ curl -s -i -H "Authorization: Bearer $TOKEN" https://api.adversarial.com/api/v1/users/meHTTP/1.1 403 ForbiddenRequest IP 1.2.3.4 not allowed for this OAuth clientThe IP the platform observed is echoed back in the message. If your callers sit behind a proxy or NAT, that’s usually the difference between “my SA works” and half an hour of debugging — check the echoed IP against the entries on the SA.
Both IPv4 and IPv6 are supported, including CIDR notation:
| Allowlist entry | Matches |
|---|---|
10.0.0.0/8 | every IPv4 in 10.0.0.0 – 10.255.255.255 |
192.168.1.42/32 | exactly 192.168.1.42 |
192.168.1.42 | exactly 192.168.1.42 (host = /32) |
fe80::/64 | every IPv6 in fe80:: – fe80::ffff:ffff:ffff:ffff |
::1/128 | exactly the IPv6 loopback |
Invalid entries are rejected at create time:
$ curl -X POST https://api.adversarial.com/api/v1/oauth/clients \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"client_name":"x","roles":["viewer"],"allowed_ips":["999.999.999.999"]}'HTTP/1.1 400 Bad Request{"errors":{"allowed_ips":[{"code":"invalid_ips","message":"Invalid IP entries: 999.999.999.999"}]}}Expiry
Section titled “Expiry”If the SA has an expiration set, the platform stops issuing and accepting tokens past that point in time:
# SA expired in the past$ curl -X POST https://api.adversarial.com/api/v1/oauth/token \ -d "grant_type=client_credentials" \ -d "client_id=…" -d "client_secret=…"HTTP/1.1 401 UnauthorizedThe expiry is checked when the token is issued and on every API call, so a token in flight at the moment the SA expires stops working on its next call. There is no grace period.
Rotation
Section titled “Rotation”The fastest way to rotate a leaked or aging secret is to reset the SA’s credentials: the platform mints a new client_secret and discards the old one in a single call, while preserving the client_id, roles, allowlist, expiry, and audit history.
curl -s -X POST https://api.adversarial.com/api/v1/oauth/clients/{id}/credentials \ -H "Authorization: Bearer $ADMIN_TOKEN"{id} is the SA’s UUID (the id field on OAuthClientResponse), not its arm_sa_… client_id. Look it up via GET /api/v1/oauth/clients. The response is the same shape as create — the new client_secret is returned exactly once and stored only as a hash afterward.
Effect on in-flight tokens:
- Access tokens issued under the old secret keep working until their 15-minute lifetime runs out. JWT bearer auth never checks the secret on the wire, so there’s no immediate revocation.
- Refresh-token requests under the old secret fail immediately with
401 Unauthorized(the refresh grant requires re-presenting the secret).
If the SA is currently disabled (revoked_at set), reset is rejected with 409 Conflict and the message Cannot reset credentials on a revoked service account; un-revoke first. PATCH revoked: false first, then reset.
When to delete instead of reset
Section titled “When to delete instead of reset”Delete is the right move when you want the SA itself gone, not just its secret:
curl -s -X DELETE https://api.adversarial.com/api/v1/oauth/clients/{id} \ -H "Authorization: Bearer $ADMIN_TOKEN"Token requests with the old credentials fail immediately, and existing access tokens stop working on their next call. The SA still appears in the Service Accounts table as Disabled, so risks and incidents it created keep their Opened By attribution.
Error reference
Section titled “Error reference”| Status | Meaning | Common causes |
|---|---|---|
400 Bad Request | Validation failure on the request body. | Empty client_name, roles array empty, malformed IP/CIDR, unknown role name. |
401 Unauthorized (token endpoint) | Invalid credentials or expired SA. | Wrong client_secret, client_id not found, SA’s expires_at is in the past. The same 401 is returned for “wrong secret” and “unknown client” so that callers cannot enumerate valid client_ids. |
401 Unauthorized (API request) | Token rejected. | Token expired (15-min lifetime), SA was deleted, SA’s expires_at is in the past, or token is otherwise invalid. |
403 Forbidden (token endpoint) | SA exists but is disabled. | Disabled from the Team page, or via PATCH with revoked: true. |
403 Forbidden (API request) | Token is valid but blocked. | Source IP not in allowed_ips; the SA was disabled after the token was issued; or the request requires a permission the SA’s roles don’t include (e.g. a Viewer SA calling POST /api/v1/risks). |
405 Method Not Allowed | Wrong HTTP verb on a known route. | Most often hitting /api/v1/risks (no list) instead of /api/v1/risks/rsk (the paginated list). |
409 Conflict (reset credentials) | Cannot reset a disabled SA. | The SA’s revoked_at is set. Un-revoke (Enable, or PATCH revoked: false) before resetting. |
Next steps
Section titled “Next steps”- API Reference — full endpoint catalog with request/response shapes.
- Managing Roles — refine which permissions an SA holds.
- Service Accounts — back to the UI walkthrough.