RFC: Stateless OAuth Client Identity for Ephemeral (DCR) Clients on a Doorkeeper-style Provider A developer proposes a design pattern for handling stateless OAuth client identity on Doorkeeper-style providers, using a JWS-based client_id for dynamically registered, ephemeral clients such as MCP agents. The approach avoids unbounded database growth by keeping client registration stateless, while requiring persisted grants and tokens with cleanup based on time and revocation state. The design includes an optional denylist extension for per-client revocation with bounded state. Category | Informational / Design Pattern | Status | Draft — distilled from production experience | Applies to | Rails + Doorkeeper OAuth providers Ruby , serving MCP or other dynamically-registered clients | Author | Aotokitsuruya(蒼時弦也) | Date | 2026-06-21 | Audience | Engineers who must host OAuth for dynamically-registered, one-time, unbounded clients typically MCP agents on top of Doorkeeper | This document records a reusable design: when an OAuth client is dynamically registered RFC 7591 DCR , one-time, and unbounded in number the MCP client is the canonical case , how to carry it on a Doorkeeper-style provider with a stateless JWS client identity — so the database never accumulates an unbounded client-registration table — and how to then handle the Access Grant / Access Token lifecycle, cleanup, revocation, and "Active Session" modeling correctly. Core conclusion: the client can be stateless a JWS , but grants and tokens necessarily persist. The real engineering problem is not "whether to store" but "how the persisted rows get reclaimed — correctly, promptly, and completely." Cleanup MUST be keyed on time and revocation state alone , never on the client/application dimension, which under this design is dynamic and non-recurring. Statelessness is a deliberate trade with real costs §4.5 ; this document states them rather than hiding them. An optional security extension a client subject denylist, §16 recovers per-client revocation at the price of a bounded amount of state — a deliberate hybrid, not pure statelessness. Normative guidance uses RFC 2119 keywords MUST / SHOULD / MAY . Code is a recommended shape, not a drop-in library; §15 lists the collaborators an adopter must supply. - Terminology - Problem Statement — and where CIMD fits - Architecture Overview - Stateless Client Identity JWS as client id - Doorkeeper Integration — application class and by uid - Grant / Token Model, Custom Attributes, and Authorize-time Population - Coexistence with Classic OAuth — Type Dispatch - Lifecycle & Cleanup — the core problem - Active Session Modeling — a decoupled metadata record - Revocation — scope it; do not use the library-wide revoke all for - Security Considerations - Operational Considerations - Pitfalls / Landmines - Decision Log - Reusability Checklist — collaborators an adopter must supply - Future Work / Unverified Extensions - References DCR — Dynamic Client Registration RFC 7591 . A client registers at runtime to obtain its client id . CIMD — Client ID Metadata Document. An alternative where the client id is an HTTPS URL the authorization server fetches to obtain the client's metadata; no server-issued identity §2.1 . Ephemeral client — a one-time client. Each instance / connection may run its own DCR and produce a non-reused client id ; the count is unbounded. MCP clients behave this way. Stateless client identity — the client's identity is encoded in a self-contained, server-signed string a JWS , verified by signature rather than by a lookup; the server stores no client-registration row. An optional denylist extension, §16, adds a bounded post-verification lookup — a hybrid. — a stable per-registration subject identifier a UUIDv7 , embedded in the JWS and recorded client subject by value on each grant/token. It is the true client-isolation key §5 . derived id — an integer deterministically derived from client subject , used as the provider's application id surrogate. It is dynamic, non-recurring, and has no backing row. It is plumbing, not an isolation key. denylist — a small set of client subject s blocked at a gate after resolution; a bounded amount of state that recovers per-client revocation §16, unverified . call-time boundary — the resource server's check, on every request, that the presented token is acceptable not revoked, not expired, right audience, owner still a member . It is the real authorization gate; this document scopes itself to the provider side and treats the boundary as adopter-supplied §15 . SoT — Single Source of Truth.- Normative keywords MUST / MUST NOT / SHOULD / SHOULD NOT / MAY follow RFC 2119. A traditional OAuth provider assumes a bounded set of long-lived clients, registered by humans or a controlled process — so storing each client with a secret in an oauth applications table is reasonable. MCP and similar agent / native-app ecosystems breaks that assumption: Clients self-register via DCR , not human registration. Clients are often one-time : each desktop instance, reinstall, or even each connection may register anew. The count is unbounded — there is no natural cap. Public clients no secret — a native app cannot keep a secret, so it uses PKCE. Persisting an oauth applications row per DCR would grow an unbounded table of mostly-dead rows . That is the problem this design solves. Key insight the heart of this design :making theclientstateless solves unbounded growth of theclient table. Butgrants and tokens still persist, so the unbounded-growth problemmovesto the access-grant and access-token tables. Anyone adopting this patternMUSTtreat "grant/token reclamation" as a first-class design problem §8 , not an afterthought. A standards-track alternative is emerging. With CIMD , the client id is an HTTPS URL the authorization server fetches to read the client's metadata document — there is no server-issued identity at all, which sidesteps the unbounded-table problem differently. The 2025-11-25 MCP authorization revision elevated CIMD to SHOULD and demoted DCR to MAY , with a registration preference order: pre-registration → CIMD → DCR → ask the user. As of mid-2026, CIMD support across clients and servers is still low in practice , so DCR remains a pragmatic choice where CIMD is not yet viable. A robust provider SHOULD support DCR for those clients and SHOULD become CIMD-compatible accept a URL client id and fetch its metadata as adoption grows — the two coexist behind the same resolution dispatch §7 . The stateless JWS-as- client id pattern in this document is one way to implement DCR without an unbounded registry. It remains preferable to CIMD when there is no client-hosted metadata URL , when identities must be issued offline , or when the AS needs to control identity expiry and rotation itself . Where CIMD fits the deployment, prefer it; this pattern is for the DCR path. ┌────────────────────────────────────────────────────────────────┐ │ Layer Owner State? │ ├────────────────────────────────────────────────────────────────┤ │ Client identity JWS signed client id STATELESS none │ │ OAuth flow Doorkeeper authz code + PKCE — │ │ Credentials/SoT access tokens / access grants PERSISTED │ │ Session metadata side table last seen, name PERSISTED thin │ │ Resource server the call-time boundary adopter-supplied│ └────────────────────────────────────────────────────────────────┘ Normative division of responsibility: - The client-identity layer MUST write no per-client-registration row. A bounded denylist keyed only on revoked subjects §16 is permitted — it is not a registration table and does not grow with registrations. - Grants and tokens are the SoT for whether a call is authenticated. - The session-metadata layer holds only what a token cannot express display name, last-seen ; it is observational and MUST NOT be the authority that gates a call. - The resource server MUST complete its call-time boundary resolve the token to scope / tenant / audience / membership before any action runs. That boundary is the adopter's; this document specifies the provider side. The DCR endpoint accepts RFC 7591 metadata redirect uris , client name , scope , … , validates it , and issues a JWS as the client id , writing nothing to the database. The endpoint is unauthenticated, so two validations are non-negotiable before issuance: redirect uri validation per RFC 7591 / RFC 8252 §7 — accept loopback §7.3 , private-use scheme §7.1 , or claimed- https §7.2, the form RFC 8252 prefers ; reject non-loopback http , wildcards, and open-redirect-prone values. This is an existing DCR / native-app requirement, not a new one: skipping it issues a signed open-redirect that PKCE does not fully contain. rate limiting of the endpoint §11 . A recommended ClientIdentity collaborator adopter-supplied; see §15 . The metadata it embeds is the validated RFC 7591 subset this design consumes — the wire names client name , redirect uris , scope the resolved Identity 's requested scope is just the alias for scope . class ClientIdentity ISSUER = "https://issuer.example" this AS's signing-domain tag see note below LIFETIME = 90.days ALG = "ES256" Identity = Struct.new :subject, :client name, :redirect uris, :requested scope, keyword init: true issue → the JWS string that becomes the client id. metadata MUST already have its redirect uris validated RFC 7591 / 8252 §7 — see above. def self.issue metadata now = Time.current.to i payload = { iss: ISSUER, sub: SecureRandom.uuid v7, iat: now, exp: now + LIFETIME.to i, reg: metadata } JWT.encode payload, KeyStore.active private key, ALG, kid: KeyStore.active kid end resolve → an Identity, or nil when the JWS does not verify fail closed . Pure signature verification — NO database lookup. The optional denylist §16 is a separate gate run AFTER resolution, not here, so this stays stateless. def self.resolve client id payload, = JWT.decode client id.to s, nil, true, algorithm: ALG, iss: ISSUER, verify iss: true, verify expiration: true, required claims: %w iss sub exp , leeway: 30 do |header| KeyStore.verification key header "kid" unknown/retired kid → nil → verification fails end reg = payload "reg" || {} Identity.new subject: payload "sub" , client name: reg "client name" , redirect uris: Array reg "redirect uris" , requested scope: reg "scope" rescue JWT::DecodeError nil end end this JWS is an iss note:internalartifact, not an OAuth/OIDC token that leaves the AS's trust domain. Its iss is a private signing-domain tag,notthe OAuth issuer URL of RFC 8414/9207; it stillMUSTbe verified so the signature is bound to this AS's key namespace. The library shown is ruby-jwt ; treat the option names as illustrative and confirm against your JWT library. A presented client id is verified by signature resolve above : signature, iss , and exp with a small leeway for clock skew, §11 , and the required claims must be present. An unknown kid a retired key yields no verification key and fails. Any failed check MUST fail closed treated as "no such client" and MUST NOT leak which check failed. If the denylist extension §16 is enabled, its gate runs after this, also fail-closed. | Property | Mechanism | |---|---| | Survives restarts | Verification is by signature, not a lookup; a restart does not affect an existing client id | | No registration table | Registration writes no row | | Retirable | exp lapses → the client re-registers; rotating the signing key invalidates every client id it signed | | Retirement is not restart-triggered | Only expiry or key rotation retires an identity — both controllable | Adopters MUST NOTuse "restart" as a retirement mechanism, andMUST NOTtreat the JWS as a session or a credential. It is anidentity: it authenticatesnothingabout whoever presents it. Anyone holding a client id JWS can begin an authorization; access requires completing PKCE and user consent, so the JWS grants nothing on its own §11 . The credential is the access token. Enabling the denylist extension §16 deliberately trades a slice of "no lookup" for per-client revocability — a hybrid, chosen knowingly. The signing key is asymmetric ES256 . A recommended KeyStore collaborator: - Keep the active private key in a secret store env / secret manager , never in source. - The JWS header's kid is a fingerprint of the public key; verification key kid is a lookup in a small map of kid → public key . Planned rotation: hold both the outgoing and incoming keys in the map for a grace period ≥ the JWS LIFETIME for zero forced re-registration . Sign new identities with the incoming key while still verifying the outgoing one; drop the outgoing key only after the grace period. Emergency rotation key compromise deliberately collapses the grace period and forces all clients to re-register — see §4.5. Statelessness is a deliberate trade. The costs are real and MUST be weighed: No single-client revocation without the §16 extension . There is no per-client row, so there is no per-client off switch. A compromised or abusive client id can otherwise be retired only by waiting out its exp or rotating the signing key which retires everyone . The §16 denylist extension blocks future authorizations for a subject — but already-issued tokens stay valid until their own expiry, so it MUST be coupled with token revocation §10 , and revocation immediacy remains bounded by the token TTL. Key-rotation blast radius. Rotating the signing key invalidates every client id it signed. Planned rotation needs the multi- kid grace period §4.4 ; emergency rotation forces all clients to re-register at once. Identifier size. A signed JWS client id is ~300–500 bytes ES256, base64url . It travels in authorize URLs mind URL-length limits , redirects, and logs, and its client subject is recorded by value on every grant and token §6 — modest storage amplification. Anyone who can read those logs can also decode the JWS payload it is signed, not encrypted , so keep no sensitive data in reg . In particular client name embedded in reg is client-supplied and may carry user- or device-identifying text; since it rides in those same URLs and logs, treat it as possibly sensitive — evaluate per deployment, or resolve it out of band instead of embedding. Acceptable, but not free. This is the single bridge that lets a stateless client sit on Doorkeeper, and it is a supported extension point . config/initializers/doorkeeper.rb the config this design depends on application class "OauthApplication" force pkce public clients MUST use PKCE S256 grant flows %w authorization code default scopes :mcp custom access token attributes :workspace id, :audience, :client subject §6 access token expires in 2.hours see §8.1 / §12.2 on TTL The hook fires for BOTH the authorize and the token endpoints, so it MUST be guarded — record active session lives only on the authorize controller §6.2 . after successful authorization do |controller, ctx| controller.send :record active session if controller.respond to? :record active session, true end In config/routes.rb — NOT this initializer: controllers is a routes DSL method. use doorkeeper do controllers authorizations: "oauth/authorizations" the authorize-time hook §6.2 end class OauthApplication < ApplicationRecord include ::Doorkeeper::Orm::ActiveRecord::Mixins::Application Doorkeeper calls this to resolve a presented client id Doorkeeper::OAuth::Client.find / .authenticate . nil → "no such client" fail closed . def self.by uid uid identity = ClientIdentity.resolve uid verify the JWS, do not look up a row return unless identity virtual uid, identity end An in-memory record marked "persisted but never saved". def self.virtual uid, identity instantiate "id" = derived id identity.subject , stable integer surrogate plumbing, not isolation "uid" = uid, "name" = identity.client name.presence || "MCP Client", "redirect uri" = identity.redirect uris.join "\n" , "scopes" = identity.requested scope.presence || Doorkeeper.config.default scopes.to s, "confidential" = false, public client "secret" = nil end client subject UUID → a stable positive bigint for Doorkeeper's integer application id. Mask to 63 bits so it always fits a signed bigint and stays non-negative. def self.derived id subject OpenSSL::Digest::SHA256.digest subject.to s .unpack1 "Q " & 0x7FFF FFFF FFFF FFFF end end Normative points: instantiate marks the object not a new record it relies on ActiveRecord::Persistence.instantiate setting @new record = false , so assigning it to a grant/token sets only the foreign key and does not autosave autosave fires only for new records . Adopters MUST use instantiate , not new . This leans on an ActiveRecord behavior; re-verify across Rails versions, not only Doorkeeper versions, and include the model's NOT-NULL columns in the hash. derived id MUST be a deterministic function of client subject landing in a positive bigint. Doorkeeper compares grant.application id == client.id integer comparison across the authorize and token requests, so the surrogate must be stable and non-nil. On the surrogate is a 63-bit hash, so two distinct derived id collisions: client subject s can in principle collide the birthday bound applies to the distinct client subject s carried by all un-cleaned grants/tokens at once , not just "live" registrations — negligible at realistic scale, but not zero . Two defenses, with different reach:- At the token exchange , a collision is non-exploitable: the PKCE code verifier is bound to the specific authorization, so a colliding client cannot redeem another's code. PKCE covers only the exchange. - Everywhere else — revocation, segmentation, session identity — never use derived id as a key; PKCE does not protect those. Key them on client subject , the true isolation key §8.5, §10, §9, Pitfalls 4/ 15 . - At the Verified against Doorkeeper 5.9.1:the authorization-code→token exchange validates the grant with grant.application id == client.id integer comparison, authorization code request.rb ; it doesnotdereference the application row. The core handshake is therefore correct with no application row present. Doorkeeper flows custom attributes from the grant onto the token it slices grant.attributes by custom access token attributes at issuance . Under that mechanism, adopters MUST add a database column for each on both the grant and token tables the slice reads from the grant . | Attribute | Purpose | Why by value | |---|---|---| audience | RFC 8707 audience binding; the token is valid only for this server | security invariant | workspace id / tenant key | the tenant the token is bound to | resolve the tenant at call time | client subject | which client = the JWS sub | the server keeps no client registry; this is the isolation key, recorded by value | Normative:any dimension you will later use to identify a client / revoke / list sessionsMUSTbe recorded by value on the token; youMUST NOTrely on resolving it through application id dynamic noise with no backing row . Custom attributes reach the token only if they are first set on the grant during authorization. Subclass the provider's authorization controller and set them in a before action : class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController before action :bind audience, :resolve tenant, :record client subject, only: %i new create private current resource owner — Doorkeeper-provided: the signed-in member. Honor the client's RFC 8707 resource , then set the audience to the validated resource; fall back to the canonical value only when none was sent. def bind audience resource = validate requested resource params :resource → the validated resource, or renders invalid target params :audience = resource || CANONICAL AUDIENCE CANONICAL AUDIENCE: this server's RFC 9728 resource id end → a tenant id the owner may authorize for, validated against its memberships; renders invalid target on a tenant the owner cannot authorize never raw from input . def resolve tenant params :workspace id = authorized tenant id for current resource owner, params end Fail-secure: trust only the verified JWS subject, overwriting any supplied value. def record client subject params :client subject = ClientIdentity.resolve params :client id &.subject end Invoked from the guarded after successful authorization hook §5 , which fires for both the authorize and token endpoints — the guard ensures this runs only here. def record active session identity = ClientIdentity.resolve params :client id client id is present on the authorize request McpSession.authorize tenant: Tenant.find params :workspace id , user: current resource owner, client subject: params :client subject , already set, fail-secure, by the before action client name: identity&.client name, expires at: Time.current + Doorkeeper.config.access token expires in end end How do theseDoorkeeper's params reach the grant? PreAuthorization reads them from the request params and slices them by custom access token attributes onto the grant when it is created; at token exchange the same slice copies them to the token. So setting them in a before action — which runs before the new / create action builds the pre-authorization — lands them on the grant. Verified against Doorkeeper 5.9.1. Helper contracts the adopter supplies: validate requested resource resource → the resource when it names this server RFC 8707 §2 / RFC 9728 id , else renders invalid target ; authorized tenant id for owner, params → a tenant id the owner may authorize for, else renders invalid target ; CANONICAL AUDIENCE → this server's canonical resource identifier the value it publishes via RFC 9728 . If one provider must serve both "classic persisted OAuth clients" and "stateless DCR clients" and, later, CIMD URL clients , they do not conflict in Doorkeeper's core; they conflict in your customization layer , which collapses to a single discriminator question. | Seam | How to coexist | |---|---| by uid resolution | super uid | | Authorize endpoint | The DCR-specific invariants audience binding, tenant binding, session recording MUST activate only on the DCR branch, and SHOULD live in a concern loaded only there, so they cannot leak onto classic OAuth | | Global config | default scopes , grant flows , TTLs are global switches. Per-context-adjustable ones custom access token expires in , per-request scope SHOULD be dispatched in a block | | Resource servers | Each endpoint guards itself; naturally no conflict. If a token-introspection endpoint RFC 7662 is exposed, it MUST answer consistently for both client types — return client subject not the raw JWS as the client id, and align active with the call-time boundary, not the decoupled session view. Introspection necessarily reads the credential tables; the decoupled session's query savings §9.2 apply only to the session list, not to introspection | Anti-pattern:writing the DCR-specific audience/tenant invariants asunconditionalbefore-actions hijacks every authorization and blocks classic clients. TheyMUSTbe conditional on the resolved type. Do notstand up two Doorkeeper providers to coexist one dispatching by uid serves all . Donotmake DCR clients persisted again to coexist — one-time clients explode the client table, which is the whole reason for statelessness. - Each authorization → one grant short TTL, typically 10 minutes; revoked at token exchange . - Each token issuance → one token row. - Without refresh tokens, an expired token requires re-running the full authorization flow which needs user consent , so DCR token TTLs are often set long days/weeks . This is a UX-vs-security trade — see §11 and §12.2. It assumes human-in-the-loop consent ; if a host can complete consent non-interactively, the long-TTL-for-UX rationale collapses — prefer short TTL with automated re-consent, which also shrinks the leak window. Doorkeeper's own StaleRecordsCleaner exposed via rake doorkeeper:db:cleanup runs queries shaped, conceptually , like: clean revoked : where.not revoked at: nil .where revoked at < now .in batches &:delete all clean expired : where.not expires in: nil .where created at < now - ttl .where