---
title: Identity assertion (ID-JAG)
description: How an agent signed in at a trusted identity provider registers with an ID-JAG, gets a user's approval for read access and selected changes, and how the provider revokes it.
---

# Identity assertion (ID-JAG)

An agent that already holds an identity from a **trusted identity provider** can register with
an **Identity Assertion JWT Authorization Grant** (ID-JAG) instead of `anonymous` or
`service_auth`. If the verified email in that assertion belongs to an Uptimeify account, the
account holder approves the agent once on the `/claim` page and chooses which changes it may
make; from then on the agent re-registers with a fresh ID-JAG and receives its token without
another approval.

This registration type is **off unless Uptimeify trusts at least one identity provider**. Check
the authorization-server metadata: only when `agent_auth.identity_types_supported` contains
`"identity_assertion"` are the endpoints on this page live. Otherwise
`POST /agent/identity {"type":"identity_assertion"}` returns `400 issuer_not_enabled` and
`POST /agent/event/notify` returns `404`.

## 1. Register with an ID-JAG

`POST https://uptimeify.io/agent/identity`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | string | yes | `"identity_assertion"` |
| `assertion_type` | string | yes | `"urn:ietf:params:oauth:token-type:id-jag"` |
| `assertion` | string | yes | The ID-JAG issued by your identity provider. |
| `scope` | string | no | Space-separated scopes you are asking for, for example `"api.read api.write.monitors"`. Unknown values are ignored; without any known scope the request counts as `"api.read"`. |

The ID-JAG must:

- be signed by a key from the JWKS of an issuer on Uptimeify's trust list (`iss`),
- carry `aud` = `https://uptimeify.io/api/`, an unexpired `exp` and a `jti` that has never been
  used before (every `jti` is accepted exactly once),
- carry `email` with `email_verified: true`,
- carry an `auth_time` no older than 3600 seconds; otherwise the answer is
  `401 login_required` with `max_age: 3600`, and you need a fresh sign-in at your provider.

```bash
curl -X POST https://uptimeify.io/agent/identity \
  -H 'Content-Type: application/json' \
  -d '{"type":"identity_assertion","assertion_type":"urn:ietf:params:oauth:token-type:id-jag","assertion":"<ID-JAG>","scope":"api.read api.write.monitors"}'
```

The response is one of three outcomes. All of them are sent with `Cache-Control: no-store`.

### Approval needed: `401 interaction_required`

The email belongs to an Uptimeify account, and this agent has not been approved yet.

```json
{
  "error": "interaction_required",
  "registration_id": "reg_…",
  "claim": {
    "user_code": "WDJB-MJHT",
    "verification_uri": "https://uptimeify.io/claim",
    "verification_uri_complete": "https://uptimeify.io/claim?claim_attempt=cat_…",
    "expires_in": 600,
    "interval": 5
  },
  "claim_token": "clm_…",
  "requested_scope": "api.read api.write.monitors"
}
```

Send your user to `verification_uri_complete`. Signed in as the account with that email, they
see what you asked for: read access, and every requested write area as a separate checkbox.
**No write area is selected by default**; the user ticks only what the agent really needs. Then
poll `POST /oauth2/token` with the claim grant and the `claim_token`, exactly as in
[Claim flow, step 4](/api/agent-auth/claim-flow#4-poll-for-completion). The success response
carries the approved `scope` and an `identity_assertion` for later exchanges.

An email match never links an account on its own: nothing is granted until the signed-in account
holder confirms on `/claim`.

### Already approved: `200` with an account assertion

The registration for your provider identity (`iss`, `sub`) has been approved before.

```json
{
  "registration_id": "reg_…",
  "identity_assertion": "<jwt>",
  "scope": "api.read api.write.monitors",
  "token_endpoint": "https://uptimeify.io/oauth2/token"
}
```

`scope` is what you asked for if the approval covers it, otherwise everything that was approved.
Without a `scope` field you receive everything that was approved. Exchange the
`identity_assertion` via the `jwt-bearer` grant as described in
[Agent authentication](/api/agent-auth#exchange-for-an-access-token).

### No matching account: `200` with `tools.public`

No Uptimeify account has this email. The agent is registered and can use the public check tools
straight away, nothing more.

```json
{
  "registration_id": "reg_…",
  "identity_assertion": "<jwt>",
  "scope": "tools.public",
  "token_endpoint": "https://uptimeify.io/oauth2/token"
}
```

## 2. What the agent may do

- **Never more than the approving user.** Every time a token is minted, Uptimeify reads that
  user's current role and customer assignment. If the role has changed since the approval, the
  token gets the current one; the role is never stored with the registration.
- **Only what was approved.** An exchange asking for a scope that was not approved is refused
  with `400 invalid_grant` (`Identity-assertion grant refused: scope_exceeds_grant.`).
- **Global supporters never write.** For such an account every write area is dropped at approval
  and again at every exchange; asking for write access alone then returns `400 invalid_grant`
  (`nothing_left`).
- **A changed customer assignment needs a new approval.** If the user's customer binding differs
  from the one approved, the exchange returns `400 invalid_grant` (`binding_changed`).
- **No user, no token.** If the approving user has been deleted, the exchange returns
  `400 invalid_grant` (`no_human`).
- Writes follow the same rules as every agent token: they need the write kill-switch on this
  deployment, and each write area reaches only its own paths; see
  [error codes](/api/error-codes-and-known-pitfalls#agent-auth-error-codes). Where Uptimeify
  records who made a change, a change made by the agent is attributed to the approving user.

## 3. Revocation

The account holder sees the agent under **Settings → Connected apps** as
`<provider host> (Agent)` and can revoke it there (see
[Connected apps](/api/oauth-connections)). Revocation ends the registration and every access token
derived from it immediately.

The identity provider can revoke it too, with a Security Event Token
([RFC 8417](https://www.rfc-editor.org/rfc/rfc8417)) pushed to Uptimeify
([RFC 8935](https://www.rfc-editor.org/rfc/rfc8935)):

`POST https://uptimeify.io/agent/event/notify`

```bash
curl -X POST https://uptimeify.io/agent/event/notify \
  -H 'Content-Type: application/secevent+jwt' \
  --data-binary '<SET>'
```

The SET must be signed by a trusted issuer, carry `aud` = `https://uptimeify.io/api/` and the
`sub` of the ID-JAG, and name one of the event types listed in the metadata under
`agent_auth.events_supported`:

- `https://schemas.openid.net/secevent/risc/event-type/session-revoked`
- `https://schemas.openid.net/secevent/caep/event-type/session-revoked`

A SET that revoked a registration is answered with `202` and an empty body. Anything else
(signature, issuer, audience, event type, unknown `sub`) is answered with `400` and
`{"err":"invalid_request"}`, without saying which check failed, and revokes nothing. The endpoint
URL is also published as `agent_auth.events_endpoint`.

## Common errors

| HTTP | `error` / `err` | Where | Meaning |
| --- | --- | --- | --- |
| 400 | `issuer_not_enabled` | `POST /agent/identity` | Uptimeify trusts no identity provider on this deployment. |
| 400 | `invalid_request` | `POST /agent/identity` | Wrong `assertion_type`, missing `assertion`, or the ID-JAG failed a check (signature, untrusted issuer, audience, expiry, reused `jti`, unverified email). |
| 401 | `login_required` | `POST /agent/identity` | `auth_time` is older than `max_age` (3600 s): sign in again at your provider. |
| 401 | `interaction_required` | `POST /agent/identity` | Not an error to retry: the account holder has to approve on `/claim` first. |
| 400 | `invalid_grant` | `POST /oauth2/token` (jwt-bearer) | The exchange exceeds the approval, the customer binding changed, the user was deleted, or nothing is left after dropping write access. |
| 400 | `expired_token` | `POST /oauth2/token` (claim grant) | As in the claim flow; also when the approving user can no longer be resolved at the moment of minting. |
| 400 | `invalid_request` (`err`) | `POST /agent/event/notify` | Wrong `Content-Type`, empty body, or the SET was not accepted. |
| 404 | - | `POST /agent/event/notify` | Uptimeify trusts no identity provider, or requested on a custom domain. |

See the [error codes reference](/api/error-codes-and-known-pitfalls#agent-auth-error-codes) for
the full list.
