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, reset the SA’s credentials to mint a new secret without losing theclient_id, roles, allowlist, expiry, or audit history.
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). Keep the secret out of your shell history and out of source control — the examples below read it from the environment:
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=$ADVERSARIAL_CLIENT_ID" \ -d "client_secret=$ADVERSARIAL_CLIENT_SECRET"The response is a standard OAuth token pair:
{ "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6...", "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6...", "token_type": "Bearer", "expires_in": 900, "scope": "risks:read risks:write incidents:read incidents:write …"}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; the exchange re-checks yourclient_idandclient_secret.expires_in— seconds until the access token expires.scope— space-separated list of the permissions the token grants, derived from the SA’s current roles.
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. The refresh grant authenticates the client on every call, so send the client_id and client_secret along with 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 "client_id=$ADVERSARIAL_CLIENT_ID" \ -d "client_secret=$ADVERSARIAL_CLIENT_SECRET" \ -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”An access token’s scope is fixed when it is issued, from the SA’s roles at that moment — but on every API call, the effective permissions are the intersection of the token’s scope and the SA’s current roles. The two directions of a role change therefore behave differently:
- Removing a role takes effect immediately: an already-issued token is limited to the SA’s current permissions on its very next API call.
- Adding a role only takes effect on the next access token. Existing tokens keep the scope they were minted with — request a new token to pick up the new role.
In short: permission removals are immediate; additions propagate within the 15-minute access-token lifetime. Disabling is likewise immediate, and source-IP allowlists and SA expiry are also re-checked on every API call.
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 = ["192.0.2.10/32"], request comes from 203.0.113.5$ curl -s -i -H "Authorization: Bearer $TOKEN" https://api.adversarial.com/api/v1/users/meHTTP/1.1 403 ForbiddenRequest IP 203.0.113.5 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 |
|---|---|
192.0.2.0/24 |
every IPv4 in 192.0.2.0 – 192.0.2.255 |
198.51.100.42/32 |
exactly 198.51.100.42 |
198.51.100.42 |
exactly 198.51.100.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} \ -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, disabled SA, or expired SA. | Wrong client_secret, client_id not found, SA disabled (from the Team page, or via PATCH with revoked: true), or 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 (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. | For example, a GET against the token endpoint, which only accepts POST. |
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.