Skip to content

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.

  • 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_…) and client_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 the client_id, roles, allowlist, expiry, or audit history.

The examples below use https://api.adversarial.com as the base URL.

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:

Terminal window
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 your client_id and client_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.

Send the access token as a bearer:

Terminal window
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.

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:

Terminal window
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.

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.

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.

Terminal window
# 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/me
HTTP/1.1 403 Forbidden
Request IP 203.0.113.5 not allowed for this OAuth client

The 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.0192.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:

Terminal window
$ 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"}]}}

If the SA has an expiration set, the platform stops issuing and accepting tokens past that point in time:

Terminal window
# 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 Unauthorized

The 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.

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.

Terminal window
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.

Delete is the right move when you want the SA itself gone, not just its secret:

Terminal window
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.

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.