# Verify an M7 API key

Use the SSO API-key verification endpoint when a server receives an M7 API key
and needs to validate its credential and stored lifecycle state. Verification
returns the key's safe policy and attribution fields so the receiving resource
can make its own authorization decision.

This endpoint is not OAuth token introspection. It does not create a login
session, authenticate an OAuth client, or authorize an action by itself. It is
intentionally absent from OpenID Connect and OAuth discovery metadata.

Create, rotate, disable, revoke, and delete keys through the M7 account
application or the
[API.User API-key management API](https://m7.org/docs/api/api.user.m7.org/api-keys).
The plaintext key is returned only when the key is created or rotated. Store it
as a secret and treat its format as opaque.

## Endpoint

```text
POST https://sso.user.m7.org/api-key/verify
```

The request requires only the API key. It does not require an M7 browser
session, user password, OAuth client credential, organization identifier, or a
second token. A resource may also ask the verifier to enforce one expected
audience, one or more required scopes, and expected personal owners or organizations. M7
uses the address supplied by Apache as `REMOTE_ADDR`; callers cannot supply or
override it in this request.

Use HTTPS from a server-side component. Do not expose a key to browser script
or send it to any host other than the intended M7 or resource endpoint.

## Send the key

The recommended transport is a Bearer header. Use an empty body when no
audience, scope, owner, or tenant check is needed:

```bash
curl --fail-with-body --silent --show-error \
  --request POST 'https://sso.user.m7.org/api-key/verify' \
  --header 'Authorization: Bearer API_KEY'
```

To require a configured audience and scopes, send a JSON or form body beside
the Bearer header:

```bash
curl --fail-with-body --silent --show-error \
  --request POST 'https://sso.user.m7.org/api-key/verify' \
  --header 'Authorization: Bearer API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "audience": "https://api.example.m7.org",
    "scope": "records.read records.write",
    "owner": ["root", "EXPECTED_OWNER_UUID"]
  }'
```

When the Authorization header is absent, send a string field named `key` and
any optional expectations in either a JSON object:

```bash
curl --fail-with-body --silent --show-error \
  --request POST 'https://sso.user.m7.org/api-key/verify' \
  --header 'Content-Type: application/json' \
  --data '{
    "key": "API_KEY",
    "audience": "https://api.example.m7.org",
    "scope": "records.read records.write",
    "owner": "root"
  }'
```

or a URL-encoded form body:

```bash
curl --fail-with-body --silent --show-error \
  --request POST 'https://sso.user.m7.org/api-key/verify' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'key=API_KEY' \
  --data-urlencode 'audience=https://api.example.m7.org' \
  --data-urlencode 'scope=records.read records.write' \
  --data-urlencode 'owner[]=root' \
  --data-urlencode 'owner[]=EXPECTED_OWNER_UUID'
```

Body fields are limited to `key`, `audience`, `scope`, `owner`, and `tenant`. Without a
Bearer header, `key` is required. With a Bearer header, `key` is ignored when
supplied because the header remains the only credential source. `audience` is
one case-sensitive string of at most 255 characters. `scope` is a whitespace-
delimited string whose tokens are normalized to lowercase; each token is at
most 191 characters.

`owner` is either one string or a nonempty flat JSON string array. In a form,
send one scalar `owner=value` or repeat the unindexed `owner[]=value` field.
Do not mix those two form shapes or use indexed, nested, or associative owner
fields. At most 32 owner entries may be supplied before duplicate removal, and
each trimmed entry may contain at most 255 bytes. Exact duplicate strings are
removed while case is preserved. Empty, control-bearing, nonstring, malformed,
duplicate scalar, or unknown fields and unsupported content types return HTTP
400.

`tenant` is one non-nil organization UUID or a nonempty flat array of those
UUIDs. Use a dashed UUID such as `11111111-1111-4111-8111-111111111111`;
organization names, handles and the nil UUID are not accepted. A form can use
`tenant=value` or repeated `tenant[]=value`. The same 32-entry limit and
malformed scalar/list rules apply. UUID matching is case-insensitive.

For an organization key, supply `tenant` instead of `owner`, for example:

```json
{
  "tenant": ["11111111-1111-4111-8111-111111111111"]
}
```

### Header precedence

If any Authorization header is present, it is the only credential source. The
header name and `Bearer` scheme are case-insensitive, but the key value is
case-sensitive.

- A valid Bearer header remains the credential source when the body contains a
  different string key. A nonempty malformed body still returns HTTP 400.
- An empty, malformed, conflicting, comma-coalesced, or non-Bearer
  Authorization header returns HTTP 400.
- A well-formed Bearer header containing an unknown or unusable key returns
  HTTP 200 with `valid: false`.
- The endpoint never falls back to a body key when an Authorization header is
  present.

The request body is limited to 2,048 bytes even when the Bearer header is the
selected credential. The Authorization header and selected key are each
limited to 512 bytes. Whitespace or control characters inside the key are not
accepted.

Never put an API key in the URL. The production route discards the query
string before verification, and a query value cannot authenticate the
request. URLs are commonly retained in access logs and browser history.

## Responses

Every response uses JSON and includes `Cache-Control: no-store`. M7 also sends
legacy no-cache headers so intermediaries do not retain verification results.

### Valid key

A key is valid only when its credential matches, its stored key record is
active, not revoked, not expired, and below its use limit when a limit exists.
A configured IP whitelist must include the direct connection address. When
the request supplies audience, scope, owner, or tenant expectations, the stored key
must also satisfy them. A successful response consumes one use atomically.

```json
{
  "valid": true,
  "data": {
    "id": "API_KEY_ROW_ID",
    "name": "Deployment monitor",
    "uid": "ISSUER_ID",
    "tenant": "00000000-0000-0000-0000-000000000000",
    "issued_for": "RECIPIENT_REFERENCE",
    "recipient_context": "platform",
    "status": "active",
    "expires": null,
    "use_limit": null,
    "uses_used": 1,
    "last_used_at": "2026-09-11 06:00:00",
    "allowed_audiences": [
      "https://api.example.m7.org"
    ],
    "allowed_scopes": [
      "records.read"
    ],
    "ip_whitelist": [
      "192.0.2.0/24"
    ],
    "metadata": {
      "environment": "production"
    }
  }
}
```

The result contains only these fields:

| Field | Meaning |
| --- | --- |
| `id` | Immutable API-key row identifier. |
| `name` | Human-readable key name. |
| `uid` | M7 account that issued the key. |
| `tenant` | Nil UUID for a personal key, or the owning organization UUID. |
| `issued_for` | Opaque attribution or scoping reference; it does not change ownership or confer authority. |
| `recipient_context` | Stored context for interpreting `issued_for`; it is not an authorization result. |
| `status` | `active` for a valid result. |
| `expires` | Concrete UTC expiration time, or `null` when the key does not expire. |
| `use_limit` | Configured positive use limit, or `null` for unlimited. |
| `uses_used` | Stored usage count after this successful verification. |
| `last_used_at` | UTC time recorded for this successful verification. |
| `allowed_audiences` | Configured resource-audience strings. |
| `allowed_scopes` | Configured permission strings. |
| `ip_whitelist` | Configured IP addresses or CIDR ranges. |
| `metadata` | Caller-managed JSON metadata, or `null`; it carries no automatic authority. |

The response never contains the plaintext key, selector, secret hash, hash
configuration, revocation internals, or other storage fields.

### Invalid or unusable key

Malformed key syntax, an unknown selector, a wrong secret, a disabled or
revoked key, an expired or exhausted key, an IP mismatch, and a supplied
audience, scope, owner, or tenant mismatch all return the same result:

```json
{
  "valid": false
}
```

The response is HTTP 200 so callers can handle every supplied but unusable
credential through one branch without learning why it failed. Failed checks do
not increment `uses_used` or change `last_used_at`. Deny the request. Do not
retry the same key to distinguish lifecycle, secret, or policy state.

### Request and service errors

| HTTP status | Meaning | Caller action |
| --- | --- | --- |
| `400` | Missing, ambiguous, oversized, malformed, or unsupported input. | Correct the request shape; do not fall back to another credential silently. |
| `405` | Method other than POST. The response includes `Allow: POST`. | Send a POST request. |
| `500` | Verification storage or service failure. | Deny the resource request and retry according to the resource's outage policy. |

Operational failures return generic safe prose. They do not expose database,
exception, or credential details.

## Apply resource authorization

`valid: true` means the API-key record, secret, stored lifecycle, configured IP
policy, and any supplied audience/scope/owner/tenant expectations passed together. It is
not a complete resource authorization decision. The receiving resource must
still:

1. supply its exact expected audience when it wants audience enforcement;
2. supply every required scope token when it wants scope enforcement;
3. supply `owner` to require a personal key from an expected user, or `tenant`
   to require an organization key from an expected organization; and
4. apply current owner, organization, resource, and operation policy.

Audience comparison is exact and case-sensitive. Scope tokens are normalized
to lowercase, and every supplied token must exactly match a configured scope;
there is no prefix or wildcard matching. Omitting audience or scope skips that
check. Supplying an expectation when its stored list is empty fails. An empty
IP whitelist accepts any direct connection address; a configured whitelist
requires an IPv4 or IPv6 address within one listed address or CIDR range.

`owner` matches personal keys only: their `tenant` must be the nil UUID, and
the expected owner matches their `uid`. Supply a user UUID or canonical M7
username such as `root`; an array means any listed user may match. Use the M7
login account name without a decorative `@`, not a profile display name or
navbar greeting. Prefer a UUID when identity must survive handle changes.

**Any supplied `owner` check fails for an organization key.** This includes its
organization UUID, its issuing user's UUID or username, and lists containing
any of those values. The issuer is attribution, not a personal owner of the
organization key.

`tenant` matches organization keys only, comparing their non-nil `tenant`
UUID against the supplied UUID or any-match UUID list. A personal key cannot
pass this check. If both `owner` and `tenant` are supplied, both are enforced:
neither is ignored, so the result is invalid. To accept either kind of valid
key, omit both expectations and inspect the returned ownership context.

A mismatch returns only `valid: false`, without consuming a use or changing
`last_used_at`. These optional assertions do not change the key's owner,
select a management context, grant management access, or reinterpret
`issued_for`. Organization `uid` remains issuer attribution.

The verifier uses the address Apache supplies as `REMOTE_ADDR`. The endpoint
never trusts raw `Forwarded` or `X-Forwarded-For` headers, and the request body
cannot provide an IP. If an operator configures trusted-proxy normalization,
that normalization is Apache's responsibility. When a resource server forwards
an end user's key to this endpoint, verification therefore normally sees the
resource server or its egress proxy, not an end-user IP supplied by that
resource. Configure the key's IP list for the actual trusted network path.

Do not infer authority from `metadata`, `issued_for`, a display name, or a
recipient context.

Personal keys carry the nil tenant UUID. Organization keys carry the owning
organization UUID, while `uid` continues to identify the authenticated issuer.
Verification reports those stored references but does not perform a current
account or organization lifecycle lookup.

A valid result does not automatically authorize the key to call API.User
management endpoints. API-key management uses the separate authorization and
ownership contract documented by
[API.User](https://m7.org/docs/api/api.user.m7.org/api-keys).

## Consumption and lifecycle

Each successful verification atomically increments `uses_used`, including for
keys with an unlimited `use_limit`, and records `last_used_at` in UTC. The
returned values reflect that committed use. For a limited key, the last
available use returns `valid: true`; the next attempt returns `valid: false`.
Concurrent attempts cannot both claim the same final use.

Call the verifier once for one intended resource operation. The use is
committed before the HTTP result is delivered. A lost response, client retry,
or downstream resource failure does not refund it automatically. Invalid
credentials and failed lifecycle, IP, audience, scope, owner, or tenant checks do not
consume a use.

Disabling a key makes verification return `valid: false`. Re-enabling it can
make the existing credential valid again if every other lifecycle check still
passes. Revocation is terminal. Rotation revokes the predecessor and creates a
new credential, so the predecessor continues to return `valid: false`.

Never log the Authorization header, request body, plaintext key, or complete
verification response. If a key may have been exposed, rotate or revoke it
through the
[M7 account application](https://user.m7.org/members/settings/api-keys) or the
[API.User management API](https://m7.org/docs/api/api.user.m7.org/api-keys).
