Skip to content

OAuth Discovery & Dynamic Client Registration

A JMAP client needs an OAuth client ID before it can start an authorization flow. Traditionally an operator creates that client ID by hand and the user copies it into the client’s settings. Serverless Inbox supports the standards-based alternative: the client discovers where the OAuth endpoints are, registers itself, and receives its own client ID — with no operator involvement.

Three RFCs do the work:

RFCWhat it provides
RFC 8414Authorization Server Metadata — the openid-configuration document
RFC 9728Protected Resource Metadata — the oauth-protected-resource document
RFC 7591Dynamic Client Registration — POST /oauth/register

Both are unauthenticated GET requests returning application/json, served by explicit API Gateway routes on the JMAP host.

The authorization server metadata. It tells a client where to send the user for authorization, where to exchange the code for tokens, which scopes exist, and — when Dynamic Client Registration is enabled — where to register. The registration_endpoint field is the client’s signal that self-registration is available: when DCR is disabled, that field is simply absent, and a well-behaved client falls back to asking the user for a manually issued client ID.

The protected resource metadata. This is the JMAP API describing itself — which authorization server guards it, and which scopes it expects. It exists so a client that starts from the mail server (rather than from the identity provider) can find its way to the right authorization server instead of being configured with one.

Not an OAuth document, but the third URL in the same namespace and the one the client uses after authenticating. It returns the JMAP session resource described in RFC 8620 §2, and unlike the two above it requires a valid token.


sequenceDiagram
participant Client
participant JMAP as JMAP host
participant DCR as /oauth/register
participant Cognito
Client->>JMAP: GET /.well-known/openid-configuration
JMAP-->>Client: endpoints + registration_endpoint
Client->>DCR: POST /oauth/register (client_name, redirect_uris)
DCR->>Cognito: Create app client
DCR-->>Client: 201 { client_id, scope, ... }
Client->>Cognito: authorization_code + PKCE (user signs in)
Cognito-->>Client: authorization code
Client->>Cognito: Exchange code + verifier
Cognito-->>Client: access token
Client->>JMAP: GET /.well-known/jmap (Bearer token)
JMAP-->>Client: session resource (apiUrl, accounts, ...)
Client->>JMAP: JMAP requests to apiUrl

Four steps, and the last one matters more than it looks:

  1. Discover — fetch openid-configuration to learn the endpoints.
  2. RegisterPOST /oauth/register to obtain a client_id. This happens once per installation of the client, not once per sign-in.
  3. Authorize — run a standard authorization_code flow with PKCE against Cognito. Serverless Inbox does not issue tokens itself; Cognito is the authorization server.
  4. Open the session — call /.well-known/jmap with the token, then follow the apiUrl from the returned session object for every subsequent API call.

That last point is a requirement of RFC 8620 §3.1, not a convention. The session resource is the only authority on where the API lives, and a client that assumes a URL layout instead of reading apiUrl will break on deployments whose layout differs — regardless of whether the server is behaving correctly.


A successful POST /oauth/register produces two things:

  • A Cognito app client — a real app client in the user pool, configured for the authorization code grant with the registered redirect URIs. This is what makes the resulting client_id usable in a real authorization flow.
  • A DynamicClient record — the server’s own record of the registration, holding the client name, redirect URIs, and the issued client ID. It is what makes deduplication and the per-tenant cap possible.

Registration is unauthenticated by design. RFC 7591 calls this open registration, and it is what allows a mail client to complete setup before the user has signed in — there is no credential to authenticate with yet. The abuse controls described below exist precisely because the endpoint is open.

The granted scope is openid email profile. A client that requests an empty scope has that default substituted rather than being rejected — RFC 7591 §3.2.1 explicitly permits the server to substitute. The scope field in the registration response always reports what was actually granted, so a client should read that field rather than assume its request was honoured verbatim.

Following RFC 8252, which covers OAuth for native applications:

Redirect URI formResult
Private-use scheme, e.g. myapp://callbackAccepted
Loopback: 127.0.0.1, [::1], localhost, any portAccepted
Non-loopback http/https, e.g. https://example.com/cbRejected
Pseudo-schemes: javascript:, data:, file:Rejected

Loopback matching is done on the parsed hostname, not by substring — http://127.0.0.1.attacker.com/ and http://127.0.0.1@attacker.com/ both contain the literal text 127.0.0.1 while resolving elsewhere, and both are rejected.


Registration is idempotent on redirect URIs plus client name. A repeat registration with the same pair returns the same client_id and creates no second Cognito app client.

This is not a cache with an expiry — the dedup is permanent. It matters because the realistic failure mode of open registration is not a determined attacker but an ordinary client that re-registers on every launch, or a user who reinstalls an app. Without dedup, each of those would consume a Cognito app client and a slot against the tenant cap. With it, they are free.

The practical consequence for client authors: re-registering is safe, but storing the returned client_id is still the right behaviour.


Because the endpoint is unauthenticated, four controls apply in order. Each one is cheaper than the one after it, so the common cases are rejected before doing real work:

  1. API Gateway per-route throttle — burst 5, rate 2. Applied at the edge, before any Lambda runs.
  2. Body and redirect-URI validation — malformed JSON, missing fields, and disallowed redirect URIs are rejected without touching Cognito.
  3. Permanent dedup — a repeat registration returns the existing client ID instead of creating anything.
  4. Per-tenant cap20 new registrations per rolling ~24 hours. Only genuinely new registrations count; deduplicated ones do not.

MethodPathAuthSuccess
GET/.well-known/openid-configurationNone200 application/json
GET/.well-known/oauth-protected-resourceNone200 application/json
GET/.well-known/jmapBearer token200 JMAP session resource
POST/oauth/registerNone (RFC 7591 open registration)201

An unmatched path under /.well-known/ returns a genuine 404.

Errors follow RFC 7591 §3.2.2: a JSON body of the form {"error": "...", "error_description": "..."}.

StatuserrorMeaning
400invalid_redirect_uriOne or more redirect URIs are not permitted
400invalid_client_metadataBody is not valid JSON, or a field failed validation
409invalid_client_metadataRegistration conflict
429invalid_client_metadataPer-tenant registration cap exceeded
500server_errorServer-side failure creating or persisting the client

A 400 is the client’s fault and will keep failing until the request changes. A 429 is temporal — the cap is a rolling window and the client can retry later. A 500 indicates a server-side problem and is worth reporting.


The standards above are settled, but JMAP client support for OAuth discovery and dynamic registration is still uneven. Some clients implement neither and expect a manually issued client ID; some implement discovery but not registration.

This documentation deliberately does not claim that any specific third-party mail client works. Two properties determine whether one will:

  • Does it perform RFC 8414 discovery, and RFC 7591 registration when registration_endpoint is present?
  • Does it follow apiUrl from the JMAP session object, or does it hardcode API paths?

A client that hardcodes endpoint paths will not work here no matter how correct the server is.