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

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:

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:

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:

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:

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:

{
  "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.

{
  "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:

{
  "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.

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 or the API.User management API.