# Token endpoint

This page describes the ordinary OAuth endpoint. Opted-in tenant browsers use
[browser-direct token pickup and refresh](https://m7.org/docs/api/sso.user.m7.org/browser-direct-authentication.md),
which sends JSON and mandatory endpoint proofs, holds refresh credentials at SSO,
and exposes only its public token package. Ordinary client authentication below
is unchanged.

```text
POST https://sso.user.m7.org/token
Content-Type: application/x-www-form-urlencoded
```

Use the token endpoint to exchange authorization codes and device codes,
refresh an existing package, obtain a service token with `client_credentials`,
or delegate user access with RFC 8693 Token Exchange. It accepts only `POST`.

Client authentication is always tied to the OAuth client's configured
`token_endpoint_auth_method`. A client must also be enabled for the requested
grant type.

## Client authentication

| Method | Request contract |
| --- | --- |
| `none` | Send `client_id` in the form body. Suitable for public clients. It cannot use `client_credentials` or Token Exchange. |
| `client_secret_basic` | Send HTTP `Authorization: Basic base64(client_id:client_secret)`. |
| `client_secret_post` | Send `client_id` and `client_secret` in the form body. |
| `client_secret_jwt` | Send `client_id`, `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`, and a signed `client_assertion`. |
| `private_key_jwt` | Send the same assertion fields. The client must have an inline `jwks` or HTTPS `jwks_uri` configured. |
| `tls_client_auth` | Present the registered CA-issued client certificate and send `client_id` in the form body to the discovered mTLS token alias. |
| `self_signed_tls_client_auth` | Present the exact registered self-signed client certificate and send `client_id` in the form body to the discovered mTLS token alias. |

For JWT client assertions, `iss` and `sub` must equal the `client_id`. The
`aud` claim may identify the canonical authorization-server issuer
`https://sso.user.m7.org` or the exact invoked endpoint URL. The assertion must
contain a current `iat`, unexpired `exp`, and unique `jti`. M7 rejects replayed
and stale assertions. Supported `private_key_jwt` signing algorithms are RSA
(`RS256`, `RS384`, `RS512`) and `ES256`; configure the intended algorithm on
the client.
`client_secret_jwt` uses a configured HMAC algorithm: `HS256`, `HS384`, or
`HS512`. Dynamic Registration selects `HS256` when that method's algorithm is
omitted and `RS256` when a `private_key_jwt` algorithm is omitted; the
effective algorithm is returned and stored authoritatively.

For either mTLS method, use
`https://mtls-sso.user.m7.org/token`, as published in
`mtls_endpoint_aliases`. The authorization-server issuer remains
`https://sso.user.m7.org`. When
`tls_client_certificate_bound_access_tokens=true`, successful issuance places
the presented leaf certificate's SHA-256 thumbprint in
`cnf."x5t#S256"`. Refresh requests and protected-resource requests must then
present the same certificate; there is no bearer fallback.

`client_secret_jwt` and HS256/384/512 token signing need recoverable HMAC key
material. API User stores it in a versioned AES-256-GCM envelope. Secret-JWT
uses it for assertion verification; HS token issuance uses the same original
secret through the protected signing handoff. API User and SSO, plus ID.M7 for
token signing, must have the same
`M7_OAUTH_CLIENT_SECRET_JWT_KEYS` key ring (64-character hexadecimal keys,
newest first); `M7_OAUTH_CLIENT_SECRET_JWT_KEY` remains a single-key fallback.
One-way legacy hashes must be replaced by setting a recoverable secret before
secret-JWT authentication or HMAC issuance. See [HMAC setup](https://m7.org/docs/api/sso.user.m7.org/registration.md#hmac-token-signing).

The current-source eligibility guard rejects a saved `none` + HS256/384/512
configuration with HTTP 400 `unauthorized_client`, before code/device pickup
or refresh exchange. Posted signing/authentication settings cannot override
the saved client. Public clients must use an asymmetric signing algorithm.
Local acceptance is complete; production verification of this restriction
remains pending. Existing credentials are not revoked by this check.

## DPoP policy

After authenticating the client, `/token` applies its registered
`dpop_policy` consistently to authorization-code, device-code, refresh-token,
and client-credentials grants:

| Policy | Required behavior |
| --- | --- |
| `disabled` | `/token` ignores DPoP and prevents new DPoP or fingerprint binding. An existing DPoP-bound refresh lineage cannot refresh under this setting; start a new authorization. |
| `optional` | Omit `DPoP` for an unbound credential, or send a valid proof to bind the issued lineage. Supplied fingerprint bindings are retained. |
| `required` | Send a valid `DPoP` header on every token request. Fingerprint binding remains optional. |

A supplied proof must be an ES256 `dpop+jwt` for the exact token endpoint and
HTTP `POST`, with a current `iat` and nonempty, per-request `jti`. Missing or
invalid required material returns `invalid_dpop_proof` before an authorization
code or device code is consumed and before token issuance begins. Generate a
new proof for every request and retry. M7 atomically retains an opaque hash of
the verified key thumbprint and `jti` for the proof window; concurrent or later
reuse of the same proof returns `invalid_dpop_proof`.

The proof sent to `/token` binds the token request itself and therefore does
not contain the protected-resource `ath` claim. Resource proofs for a returned
DPoP access token are separate and must bind the exact access token.

This client setting governs customer application tokens only. It does not
change M7's internal SSO root-session binding. See
[DPoP status](https://m7.org/docs/api/sso.user.m7.org/authorization.md#dpop-status).
Changing it does not migrate live refresh lineages. Existing packages that no
longer fit the configured policy may fail and require authorization again.

Discovery advertises ES256 through `dpop_signing_alg_values_supported`.
Dynamic Registration uses the standard `dpop_bound_access_tokens` boolean;
`true` maps to required DPoP, while omission or `false` retains opportunistic
DPoP. M7 does not currently issue `DPoP-Nonce`; clients still need a new proof
and unique `jti` for each request and retry.

## Authorization-code exchange

Send these form fields after receiving a code at the registered callback:

| Field | Required | Notes |
| --- | --- | --- |
| `grant_type` | Yes | `authorization_code` |
| `code` | Yes | The authorization code from `/authorize`. It is single-use. |
| `redirect_uri` | Yes | Exactly the same registered URI used in the authorization request. |
| `code_verifier` | Yes | The original, exact 43-to-128-character unreserved PKCE verifier. M7 verifies its SHA-256 challenge and does not trim or normalize it. |
| `fingerprint` | No | A 64-character hexadecimal M7 credential binding, when the client integration uses one. |

If the original authorization request used `dpop_jkt`, the `/token` request
must carry a valid DPoP proof from that exact key. The proof both satisfies the
authorization-code binding and determines the `cnf.jkt` sender constraint on
the issued token lineage.

Authenticate the client as described above. See the complete browser flow in
[Quickstart](https://m7.org/docs/api/sso.user.m7.org/quickstart.md).

## Token Exchange (RFC 8693)

The local-user, single-target profile is deployed and advertised. Consumer
RS256 exchange, access-only/offline selection, delegated strict refresh,
permission withdrawal and cleanup passed live acceptance on 2026-09-20.
This evidence does not establish every tenant, signing or sender-binding
combination. Check discovery's `grant_types_supported` before integration.

An authenticated confidential application can exchange its original M7 user
access token for access to one connected provider. Register the canonical
exchange grant on the requesting client and configure the provider's
[connection policy](https://m7.org/docs/api/sso.user.m7.org/registration.md#connection-configuration-m7-extensions).
The provider must permit exchange, and the connection must be active and
approved under that policy. Owner-proxy admin applications cannot use this grant.

| Field | Required | Contract |
| --- | --- | --- |
| `grant_type` | Yes | `urn:ietf:params:oauth:grant-type:token-exchange` |
| `subject_token` | Yes | Active local M7 user access token issued to the authenticated client. |
| `subject_token_type` | Yes | `urn:ietf:params:oauth:token-type:access_token` |
| `requested_token_type` | No | Omit or use `urn:ietf:params:oauth:token-type:access_token`. |
| `audience` or `resource` | Yes | Exactly one target. It must match the connection audience and both apps' registered audiences. `resource` must be an absolute URI without a fragment. |
| `scope` | No | Space-delimited subset of the source token, requesting client's allowed scopes, connection scopes and provider's exchange scope ceiling. |
| `fingerprint` | Conditional | Existing M7 fingerprint binding when required by the subject credential. |

Parameters cannot repeat. If both `audience` and `resource` are supplied, they
must identify the same target. This profile does not accept `actor_token`,
external JWT/SAML subjects, client-credentials or refresh-token subjects,
ID-token subjects, redelegation, custom claim
overrides, or HMAC token signing. Both apps must use the same identity realm:
the consumer realm, or the same organization. Tenant subjects require a
current delegated M7 identity or consumer-user link; native federated tenant
shadow credentials are not supported by this initial exchange profile.

```bash
curl --fail-with-body --silent --show-error \
  --request POST 'https://sso.user.m7.org/token' \
  --user 'CLIENT_ID:CLIENT_SECRET' \
  --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
  --data-urlencode 'subject_token=USER_ACCESS_TOKEN' \
  --data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
  --data-urlencode 'audience=https://files.example.com' \
  --data-urlencode 'scope=files:read'
```

With no `offline_access` in the requested scope, the response contains only
the access credential, even when the source authorization permits offline
access. Omitted scope uses the allowed intersection excluding `offline_access`.
Explicitly requesting authorized `offline_access` returns access and refresh
credentials together, including the normal M7 binding metadata. The requesting
client must also register `refresh_token`. Requesting
`requested_token_type=...:refresh_token` is not a bundle-selection mechanism
and is rejected. These are M7 issuance-policy choices under
[RFC 8693's optional refresh-token response](https://www.rfc-editor.org/rfc/rfc8693.html#section-2.2.1).
A refresh token is usable without presenting the previous access token.

```json
{
  "access_token": "ACCESS_TOKEN",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "files:read"
}
```

The JWT preserves the user `sub`, identifies the requesting app in `client_id`
and `act.sub`, and stamps the receiving app's registered OAuth UUID in
`m7.target_client_id`. Its `aud` contains the selected resource audience and
the issuer-added receiving-app UUID. Introspection includes `m7` and `act`.
See [requester and target claims](https://m7.org/docs/api/sso.user.m7.org/token-formats.md#requesting-client-and-receiving-application)
before applying a client restriction; `client_id` is not the receiving app.
No ID token is issued by this grant. Existing DPoP or certificate binding must
be proved and is retained; ordinary client authentication and DPoP replay
checks still apply. Initial access lifetime cannot exceed the remaining subject
token lifetime. Registered client lifetimes, the provider's exchange TTL cap,
and app/connection expiration further limit issued credentials.

Refresh an exchanged bundle using the ordinary `refresh_token` grant below.
Every refresh rechecks both registrations, current subject identity and tenant
group admission, connection approval, audiences, and the complete original
scope ceiling. It preserves the actor and target and cannot extend the
delegation's absolute offline expiration. Only scope subsets, the existing
binding/fingerprint package and permitted refresh lifecycle selection apply;
custom claims, requested lifetimes and target overrides do not change this
profile's issuance policy. Obtain a new authorization when the original scope
ceiling is no longer permitted.

Withdrawal of connection or exchange approval blocks new exchange and delegated
refresh. It does not, by itself, revoke the original source login. An offline
JWT check alone cannot observe a policy change; use the applicable live token
and [connection verification](https://m7.org/docs/api/api.user.m7.org/oauth-clients#application-connections)
checks where current permission is required.

External token acceptance is out of scope. Impersonation, multiple targets and
chained exchanges are deferred and unsupported in this profile. The
[SDK exchange guide](https://m7.org/docs/sdk/m7-identity/cli/token-exchange)
covers the current-source PHP operation and CLI command, including safe storage
and the distinction from ordinary refresh.

## Refresh-token exchange

The standard advisory binding policy permits a refresh request with the refresh
token and client authentication. M7 binding-required profiles additionally need
the stored binding package:

| Field | Required | Notes |
| --- | --- | --- |
| `grant_type` | Yes | `refresh_token` |
| `refresh_token` | Yes | The refresh credential. M7 also accepts the legacy alias `token`. |
| `binding_chain` | Conditional | Required by a binding-required profile or legacy personal-lineage signing. Save the value returned with the refresh token. |
| `binding_link` | Conditional | Send with `binding_chain` when the binding package is required or supplied. |
| `fingerprint` | Conditional | Required when the existing lineage is fingerprint-bound. Exactly 64 hexadecimal characters. |
| `new_fingerprint` | No | A replacement binding fingerprint. When `fingerprint` is supplied and `new_fingerprint` is omitted, M7 keeps the same value. |
| `scope` | No | A space-delimited subset of the authenticated refresh lineage. Omission uses the lineage's full granted scope. |
| `access_expires` | No | Requests the next access-token lifetime when allowed by policy. |
| `access_claims` | No | Requests allowed access-token claims when allowed by policy. |
| `refresh_mode` | No | M7 lifecycle extension; normally omit it. See below. |

The optional binding policy permits omission but validates a supplied package;
the required policy requires a valid complete package. Registration and
provider policy determine the mode; request input cannot select it.

The refresh token must belong to the authenticated `client_id`. Persist the
full replacement package before removing the old package. If the requested
refresh cannot proceed, keep the prior package unless the service has returned
a confirmed replacement or terminal error that makes it unusable.

A refresh request cannot add a scope absent from either the signed lineage or
the client's current registration. A requested subset limits the new access
token; it does not rewrite the refresh lineage's original maximum scope. If
client policy removes authority already present in the lineage, refresh fails
and the application must start a new authorization.

The refresh lineage's resource-audience set is immutable. Do not send `aud` on
a refresh request; obtain a new authorization or grant to change the set.
Refresh also fails when current client policy no longer allows any lineage
audience.

### Refresh lifecycle extension

Without `refresh_mode`, M7 currently uses `strict_rotation`. Supported modes
are `static`, `strict_rotation`, `grace_rotation`, `ack`, and
`ack_supersede_pending`. Most applications should omit this parameter.

Both ACK modes return an unusable pending successor while active `R0` remains
active. ACK of the eligible package activates it and revokes `R0`. The modes
differ when `R1` is already pending: `ack` rejects another `R0` refresh with
`refresh_in_progress`, while `ack_supersede_pending` lets `R0` issue `R2`,
atomically revokes never-active `R1`, and makes only `R2` eligible for ACK.
`ack_supersede_pending` is a normal lifecycle, not a recovery-only mode.

The two modes have identical client, refresh-package binding,
activation-secret, and conditional DPoP requirements. They require durable,
atomic persistence and explicit handling for lost refresh or ACK responses,
idempotent ACK retries, and terminal stale activations. See
[Token acknowledgement](https://m7.org/docs/api/sso.user.m7.org/token-acknowledgement.md) for the complete state and
error contract.

## Client credentials

Use this grant for a machine application acting as itself, never as an end
user. The client must be confidential and be enabled for `client_credentials`.

| Field | Required | Notes |
| --- | --- | --- |
| `grant_type` | Yes | `client_credentials` |
| `aud` | Yes | One or more exact requested audiences from the client's registered `allowed_audiences`. Repeat the field for multiple values. |
| `scope` | No | Requested space-delimited scope string, subject to client policy. |
| `access_expires` | No | Requested access lifetime. `expires_in` and `expires` are accepted aliases. |
| `claims` | No | Requested JSON claims object. `payload` is an accepted alias. |
| `fingerprint` | No | 64-character hexadecimal M7 binding. |
| `label` | No | A management label for the issued record. |

Example:

```bash
curl --fail-with-body --silent --show-error \
  --request POST 'https://sso.user.m7.org/token' \
  --user 'CLIENT_ID:CLIENT_SECRET' \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode 'aud=https://api.example.com' \
  --data-urlencode 'scope=file.read file.write' \
  --data-urlencode 'access_expires=900'
```

This flow returns an access token only; it does not issue a refresh token. A
client using `token_endpoint_auth_method=none` is rejected for this grant.

## Device-code exchange

Use this only after obtaining `device_code` from
[`/device_authorization`](https://m7.org/docs/api/sso.user.m7.org/device-authorization.md):

| Field | Required | Notes |
| --- | --- | --- |
| `grant_type` | Yes | `device_code`. The standard `urn:ietf:params:oauth:grant-type:device_code` identifier is also accepted. |
| `device_code` | Yes | Secret device code returned by `/device_authorization`. |

Authenticate the same client that initiated the device flow. If the user has
not approved yet, the response is `authorization_pending`. Observe the
response's `interval`; do not poll more frequently. A device code is consumed
after successful pickup.

## Token responses

The response varies by grant and client policy. A user-authorized success
typically contains:

```json
{
  "token_type": "Bearer",
  "access_token": "ACCESS_TOKEN",
  "expires_in": 900,
  "refresh_token": "REFRESH_TOKEN",
  "refresh_expires_in": 2592000,
  "binding_chain": "BINDING_CHAIN",
  "binding_link": "BINDING_LINK",
  "scope": "openid profile",
  "id_token": "ID_TOKEN"
}
```

For authorization-code and device-code flows, `id_token` is returned when
`openid` was granted. RFC 8693 exchange does not return an ID token. An access token from
`client_credentials` does not have a user refresh package. Treat every token
and every binding or activation field as a secret.

The response above is the OAuth token-package envelope, not the decoded access
token payload. See [Access-token formats](https://m7.org/docs/api/sso.user.m7.org/token-formats.md) for the consumer,
tenant-member, and OAuth-application JWT claim profiles.

The example is an unbound response. M7 derives `token_type` from the access JWT
in every supported grant and refresh envelope:

- `Bearer` means the access token has no `cnf` sender constraint;
- `DPoP` means the complete `cnf` object contains only a valid `jkt`; and
- a malformed, legacy, mixed, or ambiguous confirmation shape fails closed
  with `server_error` instead of returning the access token.

For `DPoP`, retain the private key for the complete lineage and use the exact
authorization and proof contract required by each protected resource. See
[DPoP status](https://m7.org/docs/api/sso.user.m7.org/authorization.md#dpop-status).

## Errors

Errors use OAuth JSON fields such as `invalid_request`, `invalid_client`,
`invalid_grant`, `unauthorized_client`, `invalid_scope`, `invalid_target`, and
`unsupported_grant_type`. `invalid_dpop_proof` means the required proof was
missing or the supplied proof was invalid. Do not branch on prose in
`error_description`.

An unexpected persistence, configuration, dependency, or processing failure
returns `server_error` with the safe description `An unspecified error
occurred. Please try again.` and an opaque `trace_id`. Retain the trace ID for
support correlation only. Sanitized internal failure metadata is logged
privately. Raw exception text and downstream diagnostics are never part of the
token response.
