# Authentication

How the connector authenticates: discovery, dynamic registration, PKCE, the resource binding, token lifetimes and revoking a connection.

- Source URL: https://www.signcustomiser.com/help/mcp/guides/authentication/
- Markdown URL: https://www.signcustomiser.com/help/mcp/guides/authentication.md

## Guide

The MCP endpoint is protected by an OAuth 2.1 authorization server that Sign Customiser runs for this one resource. A client discovers it, registers itself, sends the merchant through consent, and receives a token bound to one store. Nothing here is shared with the public partner API: `/api/v3` authenticates with store API keys, and no key ever reaches the MCP endpoint.

This guide is for people building or debugging a client. If you only want to connect one, read [Connect an assistant](/help/mcp/guides/connect/).

## Discovery

An unauthenticated call to the endpoint answers `401` with a challenge that names where to look:

```http
WWW-Authenticate: Bearer resource_metadata="https://web.signcustomiser.com/.well-known/oauth-protected-resource/mcp"
```

That pointer is the whole of client discovery. Two documents follow from it.

**Protected-resource metadata (RFC 9728)** is served at both `/.well-known/oauth-protected-resource` and `/.well-known/oauth-protected-resource/mcp`; clients differ over which they try, and both answer the same document. It names the exact resource, this application as its authorization server, and every scope that can be requested.

**Authorization-server metadata (RFC 8414)** is served at `/.well-known/oauth-authorization-server` and `/.well-known/oauth-authorization-server/mcp`. Every value in it is something the server enforces:

| Field | Value |
| --- | --- |
| `issuer` | `https://web.signcustomiser.com` |
| `authorization_endpoint` | `https://web.signcustomiser.com/oauth/authorize` |
| `token_endpoint` | `https://web.signcustomiser.com/oauth/token` |
| `registration_endpoint` | `https://web.signcustomiser.com/oauth/register` |
| `revocation_endpoint` | `https://web.signcustomiser.com/oauth/revoke` |
| `grant_types_supported` | `authorization_code`, `refresh_token` |
| `code_challenge_methods_supported` | `S256` |
| `token_endpoint_auth_methods_supported` | `none` |

`authorization_response_iss_parameter_supported` is `false`, and it is honest: the authorization response carries no RFC 9207 `iss` parameter, so a client takes its issuer assurance from discovery rather than from the redirect.

## Dynamic client registration

`POST /oauth/register` implements RFC 7591, so a client that has never met this server can register itself and start an authorization request immediately. Nothing authenticates that request, which is why it is bounded:

- **Public clients only.** No secret is issued and none is accepted, so a registration is worth nothing on its own.
- **Redirect URIs must be HTTPS, or loopback HTTP.** No fragment, no credentials in the authority, no wildcards, no other schemes. The authorization request is then matched against the registered list exactly.
- **Authorization code and refresh only**, response type `code` only.
- **Per-IP quotas**, hourly and daily, spent on every attempt including the ones that fail validation.
- **Unused registrations are reclaimed.** A registration that never obtained a token, or whose last use is old, is pruned.

Registering grants nothing. A merchant still has to approve scopes, and the consent screen marks a self-registered client as unverified.

## PKCE

PKCE is mandatory and `S256` is the only accepted method. A request with `plain`, or with no `code_challenge` at all, is refused before the authorization server sees it, and the refusal is answered directly rather than redirected: a malformed PKCE parameter means the request cannot be trusted to say where its answer should go.

A failed exchange burns the authorization code. Present the wrong `code_verifier`, or none at all, and the code is revoked before the error comes back, so a retry with the right verifier is refused with `invalid_grant` too. Start a fresh authorization request rather than re-presenting the code.

## Resource binding

Send the RFC 8707 `resource` parameter on the authorization request:

```text
resource=https://web.signcustomiser.com/mcp
```

Naming another API is refused with `invalid_target`. Omitting it means the canonical resource, which is the only resource this server has.

The binding is recorded on the token itself, not only in the JWT audience claim, and the resource server rejects any token whose resource is not its own. It survives refresh rotation: a rotated token carries the original store and resource forward, so a client cannot change which store it acts for by refreshing, even if the same person has since authorised the same client for a different store.

## What a token carries

A token is issued for one client, on behalf of one person, for one store, with the scopes that person approved. There is no wildcard scope. Three things are checked on every single request, not at connection time:

- the signature and the row expiry, so shortening a live token's life takes effect on the next call;
- the resource binding;
- that the person who approved the connection still belongs to the store's company. A grant dies with their access.

The store comes from the token. No request parameter can change it.

The token response does not carry a `scope` parameter. RFC 6749 makes it optional when the granted scopes are identical to the ones requested, which is the only case this server issues in: a merchant either approves the request as it stands or denies it, so nothing is ever quietly narrowed. A client that wants to confirm what it actually holds should call `get_store`, which publishes the connection's granted scopes as `scopes`, rather than read them back off the token response.

## Token lifetimes

| Token | Lifetime |
| --- | --- |
| Authorization code | 10 minutes |
| Access token | 60 minutes |
| Refresh token | 30 days, rotated on every use |

An idle connection therefore lasts 30 days. A leaked access token stops working within the hour.

## Revocation

`POST /oauth/revoke` implements RFC 7009. Revoking an access token revokes its refresh token and the reverse, because a client asking to be forgotten means the whole grant.

Clients are public and hold no secret, so the client authentication RFC 7009 expects is the `client_id` the token was issued to: a caller has to both hold the token and name its client. A token this server never issued, or one already gone, answers `200` with nothing revoked, so the endpoint cannot be used to probe for live tokens.

A merchant can revoke from **Tools & Settings > MCP server** without the client's cooperation. That path revokes every access token behind the connection, their refresh tokens, and any authorization code not yet exchanged.

## Errors a client should handle

| Response | Meaning | What to do |
| --- | --- | --- |
| `401` with a `resource_metadata` challenge | No token, an expired token, or one bound to another resource | Run discovery and re-authorise |
| `invalid_target` on authorize | The `resource` parameter named another API | Send the canonical resource |
| A tool result carrying `insufficient_scope` | The connection is valid; the merchant did not approve that permission | Ask the merchant to reconnect and approve the scope named in `required_scopes` |
| `429` | The endpoint's own rate limit | Wait for `retry_after` |

An insufficient scope is a tool result rather than a protocol error on purpose. The connection is working, the model asked for something the merchant did not allow, and telling the model the server broke would be wrong.
