| 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_classandby_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_idis 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.)
client_subject— a stable per-registration subject identifier (a UUIDv7), embedded in the JWS and recorded 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'sapplication_idsurrogate. It is dynamic, non-recurring, and has no backing row. It is plumbing, not an isolation key. - denylist — a small set of
client_subjects 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 the client stateless solves unbounded growth of the client table. But grants and tokens still persist, so the unbounded-growth problem moves to the access-grant and access-token tables. Anyone adopting this pattern MUST treat "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-loopbackhttp, 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
issnote: this JWS is an internal artifact, not an OAuth/OIDC token that leaves the AS's trust domain. Itsissis a private signing-domain tag, not the OAuth issuer URL of RFC 8414/9207; it still MUST be verified so the signature is bound to this AS's key namespace. (The library shown isruby-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 NOT use "restart" as a retirement mechanism, and MUST NOT treat the JWS as a session or a credential. It is an identity: it authenticates nothing about whoever presents it. Anyone holding a
client_idJWS 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
kidis a fingerprint of the public key;verification_key(kid)is a lookup in a small map ofkid → public key. - Planned rotation: hold both the outgoing and incoming keys in the map for a grace period (≥ the JWS
LIFETIMEfor 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_idcan otherwise be retired only by waiting out itsexpor 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_idit signed. Planned rotation needs the multi-kidgrace period (§4.4); emergency rotation forces all clients to re-register at once. - Identifier size. A signed JWS
client_idis ~300–500 bytes (ES256, base64url). It travels in authorize URLs (mind URL-length limits), redirects, and logs, and itsclient_subjectis 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 inreg. In particularclient_name(embedded inreg) 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)
# endclass 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
endNormative points:
instantiatemarks the object not a new record (it relies onActiveRecord::Persistence.instantiatesetting@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 useinstantiate, notnew. (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_idMUST be a deterministic function ofclient_subjectlanding in a positive bigint. Doorkeeper comparesgrant.application_id == client.id(integer comparison) across the authorize and token requests, so the surrogate must be stable and non-nil.- On
derived_idcollisions: the surrogate is a 63-bit hash, so two distinctclient_subjects can in principle collide (the birthday bound applies to the distinctclient_subjects 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_verifieris 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_idas a key; PKCE does not protect those. Key them onclient_subject, the true isolation key (§8.5, §10, §9, Pitfalls #4/#15).
- At the token exchange, a collision is non-exploitable: the PKCE
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 does not dereference 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 sessions MUST be recorded by value on the token; you MUST NOT rely 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
endHow do these
paramsreach the grant? Doorkeeper'sPreAuthorizationreads them from the request params and slices them (bycustom_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 abefore_action— which runs before thenew/createaction 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 as unconditional before-actions hijacks every authorization and blocks classic clients. They MUST be conditional on the resolved type.
Do not stand up two Doorkeeper providers to coexist (one dispatching
by_uidserves all). Do not make 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(<adapter-specific expiration SQL> < now).in_batches(&:delete_all)Verified fact (Doorkeeper 5.9.1): both queries key only on
revoked_at/created_at+expires_in— they never touchapplication_id.
Consequences:
- The dynamic, non-recurring
application_id(§5) has no effect on cleanup — cleanup deletes each row by its own time/revocation state and never needs to know which client it belonged to. - Adopters MUST clean up by time + revocation only. A
dependent: :delete_allcascade from the application would never help here anyway (there is no application row to cascade from), so it plays no part. application_idis not a usable discriminator for cleanup or segmentation (per-registration noise with no backing row); useaudience/client_subjectinstead (§8.5).- The
oauth_applicationstable stays empty; there is nothing to clean there.
A row escapes both queries only when revoked_at IS NULL and expires_in IS NULL (an "immortal" row).
Normative: under this reclamation model, adopters MUST NOT issue non-expiring tokens (
expires_inalways set). With that held, every row is eventually reclaimed: revoked rows byclean_revoked(prompt, TTL-independent), naturally expired rows byclean_expired(after their TTL). No immortal rows, no unreachable rows. (This bounds storage; it does not bound a leaked token's validity window — see §11.)
Wrap the native cleaner so the logic is a class method reproducible in any environment (console, test, rake) — not only where the schedule runs — and schedule it as a job:
class OauthCredentialCleanupJob < ApplicationJob
queue_as :default
def self.cleanup
tokens = Doorkeeper.config.access_token_model
grants = Doorkeeper.config.access_grant_model
Doorkeeper::StaleRecordsCleaner.new(tokens.where(refresh_token: nil))
.clean_expired(Doorkeeper.config.access_token_expires_in)
Doorkeeper::StaleRecordsCleaner.new(tokens).clean_revoked
Doorkeeper::StaleRecordsCleaner.new(grants)
.clean_expired(Doorkeeper.config.authorization_code_expires_in)
Doorkeeper::StaleRecordsCleaner.new(grants).clean_revoked
end
def perform = self.class.cleanup
end# config/recurring.yml (Solid Queue)
production:
oauth_credential_cleanup:
class: OauthCredentialCleanupJob
schedule: every hour- Two cadences, not one.
clean_revokedSHOULD run frequently (e.g. hourly) regardless of TTL, so a revocation is reflected in storage promptly;clean_expiredonly needs to keep pace with the TTL. Do not collapse both into a single "run every TTL" schedule — under a long TTL that would let revoked rows pile up. A single hourly job calling all four steps satisfies both. - SQLite is supported by the native cleaner's adapter-specific expiration SQL; other adapters not in the support table degrade to the coarse
created_atfilter (with a warning) — provide acustom_expiration_time_sqlthere. - Refresh-token caveat:
clean_expiredskips rows with a non-nullrefresh_token(they may be refreshed). If you enable refresh tokens (§12.2), those rows are reclaimed only byclean_revokedor refresh-token rotation — design that lifecycle explicitly before turning refresh on. This document's cleanup model assumes no refresh tokens.
- Single TTL (DCR-only): the native cleanup as scheduled is sufficient.
- Mixed TTL (long DCR + short classic OAuth):
clean_expired's coarse filtercreated_at < now - globalTTLis keyed to a single global TTL. If the global is the longest value, short-TTL tokens linger until that window. Run a segmented cleanup:StaleRecordsCleaneraccepts any relation + an explicit ttl, so segment byaudience(or scope) and pass each segment's TTL. If a segment uses a longercustom_access_token_expires_in, the globalaccess_token_expires_inMUST be ≥ the largest segment TTL, or the coarse filter under-collects.
Requirement: show "which clients a member authorized, when each was last seen, and whether each is still live."
An Active Session is closer to a client than to a token: one authorization aggregates many rotating tokens, so the session persists across token rotation and is not 1:1 with a token. Model it as a decoupled metadata record, keyed by (tenant, member, client_subject), holding its own validity window plus the observational fields a token cannot express. A recommended shape (adopter-supplied):
# Table: tenant_id, user_id, client_subject (unique together),
# client_name, expires_at, last_seen_at, timestamps
class McpSession < ApplicationRecord
RECENCY = 1.hour # deployment-set display window
scope :active, -> { where(last_seen_at: RECENCY.ago..) } # shown vs hidden
# Born at consent; the validity window is advanced on each re-authorization.
def self.authorize!(tenant:, user:, client_subject:, client_name:, expires_at:)
find_or_initialize_by(tenant_id: tenant.id, user_id: user.id, client_subject: client_subject)
.update!(client_name: client_name, expires_at: expires_at) # = now + access_token_expires_in
end
# Observed on each authenticated call, throttled to a minute (do not write the auth-hot path hard).
def self.touch_seen(tenant:, user_id:, client_subject:)
rec = find_by(tenant_id: tenant.id, user_id: user_id, client_subject: client_subject)
return if rec.nil? || (rec.last_seen_at && rec.last_seen_at > 1.minute.ago)
rec.update_column(:last_seen_at, Time.current)
end
# Held vs discarded — its own lifecycle, independent of token cleanup.
def self.prune_expired = where(expires_at: ..Time.current).delete_all
endDisplay and retention are two orthogonal filters: shown vs hidden = last_seen_at within the recency window; held vs discarded = the session's own expires_at passed.
You could instead define active = EXISTS a non-revoked, unexpired token for (tenant, member, client_subject). That existence query is the same shape as the scoped-revoke scan (§10) and needs no extra index either — so "avoiding an index" is not a real reason to prefer one over the other. The honest trade-off is:
| Option | Benefit | Cost |
|---|---|---|
| Decoupled | the session is a client-level record that persists across token rotation; the list renders without touching the credential tables | brief staleness after a revoke (the record ages out by its own expiry / recency) |
| Token-projected | the list is exact at all times | a credential-table existence query on every render, and coupling a client-level view to token-level rows |
The choice turns on one observable property: does the list offer a per-row revoke action?
- Display-only list (no per-row revoke) → decoupled is the right default. The list is observational; the real gate is the call-time boundary, which rejects a revoked/expired token regardless of what the list shows; brief staleness is harmless. A new token under the same authorization keeps the session legitimately live, which last-seen recency reflects exactly.
- List drives revocation (a per-row revoke control) → decoupled is the wrong default. An operator acting on a stale row could revoke the wrong thing, or see a just-revoked client still shown as live. There the session MUST reflect true credential state — either project liveness from tokens, or revoke-and-remove the session record in one step (so the action the operator just took is visibly reflected). Evaluate this against your roadmap, not only today's UI: if a per-row revoke action is planned (§16), adopt the revoke-synced shape now — decoupled-vs-projected is a schema decision costly to reverse later.
Prefer decoupled where it applies (the common display-only case) because the session is conceptually a client that persists across token rotation, and the list renders without touching the credential tables.
- The session's
prune_expired(by its own expiry) is independent of token cleanup — two independent entities, two reclamations. Intentional, not a smell. - Member-removal revocation (§10) acts on tokens. For a display-only list the session record simply ages out; for a revocation-driving list, remove/expire the session record at revocation time (§9.2).
When access must be cut off (member removal now; per-client "end session" via §16), the credential must actually stop working, not merely disappear from a list.
Verified fact (Doorkeeper 5.9.1): the built-in
revoke_all_for(application_id, resource_owner)keys only on(application_id, resource_owner)— it has no tenant filter.
Because one stateless client (client_subject → one derived_id) can be authorized in multiple tenants by the same user, revoke_all_for would over-revoke across tenants in a multi-tenant deployment.
Normative:
- In a multi-tenant deployment, revocation MUST be scoped to the full granularity it targets and MUST NOT use
revoke_all_for. (In a strictly single-tenant deployment the over-revoke risk does not arise;revoke_all_foris then acceptable.) Member removal scopes by(tenant_id, resource_owner_id); a per-client "end session" addsclient_subject. - Revocation MUST act on both tokens and any unredeemed grants (an in-flight grant could otherwise be exchanged into a token after revocation — a race window).
- Per-token revocation (revoking a single token by its id) is cheap and needs no client registry; expose it together with an enumeration entry (§11/§15) so a leaked token can actually be located and killed without waiting out a long TTL.
# On member removal: revoke that member's credentials for this tenant, scoped.
def revoke_member_credentials(user_id)
now = Time.current.utc
[ Doorkeeper::AccessToken, Doorkeeper::AccessGrant ].each do |model|
model.where(resource_owner_id: user_id, workspace_id: id, revoked_at: nil)
.update_all(revoked_at: now)
end
end- After revocation the token is immediately inactive (the call-time boundary checks
revoked_at), and the scheduled cleaner'sclean_revokedreclaims the row — a closed loop: revoke → reclaim. - Member removal SHOULD combine active revocation (above) with the call-time membership re-check as defense in depth: even if a revoke is missed, the boundary still denies a removed member.
- Audience binding (RFC 8707). RFC 8707 is client-driven: the client sends one or more
resourceparameters, and the AS applies an audience restriction. The verifiable invariant: a provider MUST NOT unconditionally overwrite a validly-sentresource. Validate eachresourceagainst the set the resource-owner is authorized to mint tokens for — not merely "names this server": a server exposing several resource variants must not let a client bind a token to a variant outside the owner's grant (the same authorization checkresolve_tenantalready applies to the tenant). Set the token's audience to the validated resource(s), falling back to the canonical value only when none is sent (a single-audience deployment collapses to it), and handle multipleresourcevalues explicitly rather than silently keeping one. The audience is expressed on the token (RFC 7519aud/ RFC 7662) and MUST be compared at the call-time boundary. - DCR redirect_uri validation. The DCR endpoint is unauthenticated; it MUST validate
redirect_urisat registration per RFC 7591 / RFC 8252 §7 (loopback or private-use scheme; reject non-loopbackhttp, wildcards) before issuing aclient_id. Skipping this issues a signed open-redirect that PKCE does not fully contain. client_subjectfail-secure. MUST trust only thesubfrom the verified JWS; a same-named value in the request MUST be overwritten (§6.2).- JWS is not a credential. The
client_idJWS appears in authorize URLs and logs and is not a secret. It authenticates nothing about the presenter; security comes from PKCE possession + audience + user consent. Do not treat JWS validity as an access-control lever or replay defense. (Its payload is signed, not encrypted — readable by anyone with the log.) - PKCE. A stateless client is public (no secret), so the provider MUST
force_pkcewith S256 (RFC 7636 / RFC 8252). PKCE is also what makes aderived_idcollision non-exploitable at the token exchange (§5) — but only there. - DCR endpoint abuse / DoS. Statelessness stops the client table from growing, but each issued
client_idcan drive authorizations that create grant/token rows — so the DoS surface moves to those tables. Rate-limit the DCR/authorize/token endpoints (Rails 8 shipsrate_limit; Doorkeeper supports throttling — neither is on by default). Note the DCR endpoint has no stable client identity to limit by, so it can only be limited coarsely (per-IP/global), which an attacker can evade by rotating IPs; the real defense for grant/token DoS is the tight cleanup schedule (§8.4) plus a bounded token TTL. - Token leak window vs long TTL. With long TTLs and no refresh, a leaked bearer token stays valid for that TTL, and member-removal / membership-recheck do not help if the member is still valid. To kill a leaked token you must first find it: the decoupled session (§9) is client-level and carries no token id, so provide an admin entry that enumerates a member's live tokens by
(tenant, owner, client_subject)and revokes by id (cheap, no registry). Provide that, or prefer short access tokens + refresh rotation (OAuth 2.0 Security BCP) — long TTL with neither leaves a multi-week leak window. - Member removal is immediate. Removal MUST revoke the member's credentials (§10), and the boundary SHOULD independently re-check membership.
- Tenant is a selector, not a source. A tenant named in the request MUST only be matched against the token's tenant, never used as its source.
- Fail closed. Any failed boundary check MUST collapse to one unauthenticated outcome, not leaking whether a resource exists. A denylist (§16) lookup failure MUST also fail closed.
- Clock skew. Cleanup and
exp/expires_inchecks compare against wall-clock time. In a multi-node deployment a cleaner whose clock runs fast can delete a token slightly before its real expiry, and verifiers can disagree on validity. Keep nodes on NTP, allow a smallleewayonexpverification (§4.1), and centralize the comparison on a single clock source (e.g. the database'snow()via the provider's expiration SQL). Note this centralizes, not eliminates, skew — and a per-node (non-shared) SQLite deployment does not get this benefit; there, let a single node own expiry decisions and give verifiers adequateleeway. - Key rotation. Rotating the signing key retires every
client_id; use the multi-kidgrace period (§4.4) for planned rotation. - Audit (a named compensating control). Discarding the client table also discards the natural registration history — so the only trace of who registered when is the audit log. Security-relevant events (DCR registration, authorization, revocation, member removal) SHOULD be logged; for a design that deliberately keeps no client registry, registration audit is the compensating control for after-the-fact investigation, not an optional nicety.
- Adopters SHOULD NOT add or remove indexes on, or migrate, the OAuth provider's own tables for their feature's query patterns. Adding columns through the provider's own
custom_access_token_attributesmechanism is supported (the provider knows about them); reaching in to re-index the provider's tables for your access pattern fights the library across upgrades. - You will not need such an index: scoped revocation (§10) filters by
resource_owner_id(already indexed by the provider) plus the tenant key — a tiny scan within one owner's rows, run rarely; and the decoupled Active Session (§9) does not query the credential tables at all. (This is a "you won't need it" observation, not the reason the session is decoupled — that reason is in §9.2.) - The provider's default
application_idindex becomes high-cardinality and unused under this design (every registration is a distinct id), but it is harmless write overhead bounded by live rows. Leaving it respects the provider's schema ownership.
- With no refresh and human-in-the-loop re-authorization, token TTL SHOULD be set long to avoid frequent consent — but weigh the leak window (§11): pair a long TTL with per-token revocation (§10), or prefer enabling refresh tokens (with a designed cleanup lifecycle, §8.4) where the blast radius matters.
- When using a per-segment
custom_access_token_expires_in, the global TTL MUST be ≥ the largest segment TTL (§8.5).
Verified fact (Doorkeeper 5.9.1):
revoke_previous_authorization_code_tokencallsrevoke_previous_tokens(grant.application, ...)→application.idwith no safe-navigation; under stateless identitygrant.application(loaded by theapplication_idassociation, which has no backing row) is nil →nil.id→ NoMethodError.
revoke_previous_authorization_code_tokenandrevoke_previous_client_credentials_tokenMUST NOT be enabled until the nil-application path is handled (return a virtual application for thegrant.applicationassociation, or guard the nil).reuse_access_tokenis not in the same crash class: its matching readsapplication&.scopes(safe-navigated) and, in the authorization-code flow, the client is the virtual app (non-nil). It still involves the application object, so verify before enabling — but it does not crash on a nil application the wayrevoke_previous_*does. Verdict: leave it off — this design needs no token reuse, and enabling it only adds matching over the virtual app to re-verify for no benefit.
| # | Pitfall | Consequence | Rule |
|---|---|---|---|
| 1 | Persist one-time clients | client table grows unbounded | use a stateless JWS (or CIMD) |
| 2 | Assume statelessness removes unbounded growth | grant/token tables grow instead | treat cleanup as first-class |
| 3 | Expect an application cascade to clean up | no application row → it cannot help | clean by time/revocation |
| 4 | Use application_id as a cleanup/revoke/segment key |
dynamic noise; collision mis-targets | use audience / client_subject |
| 5 | Issue non-expiring tokens (this model) | immortal rows | expires_in always set |
| 6 | One "every TTL" cleanup cadence | revoked rows pile up under long TTL | run clean_revoked frequently; clean_expired per TTL |
| 7 | Single global coarse filter under mixed TTL | short-TTL tokens linger | segmented StaleRecordsCleaner |
| 8 | Decoupled session for a list that drives revocation | operator acts on a stale/just-revoked row | decoupled only for display-only lists; project or revoke-sync when a per-row revoke action exists |
| 9 | Revoke with library-wide revoke_all_for (multi-tenant) |
cross-tenant over-revoke | scope by (tenant, owner[, client_subject]) |
| 10 | Revoke tokens but not grants | in-flight grant race | revoke both |
| 11 | DCR invariants as unconditional before-actions | hijacks classic OAuth authorization | conditional on resolved type |
| 12 | Enable revoke_previous_* |
dereference nil application → crash | handle the virtual application first |
| 13 | Build the virtual app with new instead of instantiate |
autosaves an application row | use instantiate |
| 14 | Leave the DCR endpoint unthrottled / its redirect_uris unvalidated | grant/token DoS; signed open-redirect | rate-limit; validate redirect_uris (RFC 8252 §7) before issuing |
| 15 | derived_id treated as the isolation key |
collision → wrong-client confusion | client_subject is the isolation key; PKCE backstops only the exchange |
| 16 | Unconditionally overwrite the client's resource |
breaks RFC 8707 client-driven audience | set audience to the validated resource; canonical is fallback |
| Decision | Trade-off | Conclusion |
|---|---|---|
| Persisted client vs stateless JWS | one-time clients explode the table | stateless (hard requirement for DCR) |
| DCR vs CIMD | CIMD is now SHOULD (low support today); DCR is MAY | DCR where CIMD isn't viable; become CIMD-compatible as it lands (§2.1) |
| Signing algorithm | symmetric vs asymmetric | ES256 (asymmetric, ships with Ruby's OpenSSL) |
application_id surrogate |
nil breaks validate_grant's integer compare |
non-nil per-registration derived_id, masked to a positive 63-bit integer; client_subject (by value) is the true isolation key |
| Custom prune vs the library's native cleaner | the native StaleRecordsCleaner already covers it and supports SQLite |
native cleaner, wrapped in a scheduled job/class method |
| Token-derived session vs decoupled session | projection is exact but couples client-level to token-level; decoupled is simpler but briefly stale | decoupled for display-only lists; project or revoke-sync when the list drives revocation |
revoke_all_for vs scoped revoke |
the built-in lacks a tenant filter | scoped by (tenant, owner) in multi-tenant; revoke_all_for acceptable only single-tenant |
| Touch the provider's schema vs leave it | re-indexing the provider's tables is intrusive and unnecessary | leave the provider's schema alone |
| Per-client revocation | full statelessness gives it up | optional denylist + per-token revoke extension (§16) — a deliberate bounded-state hybrid, unverified |
This document specifies the provider-side design; an adopter MUST supply these collaborators to reach an equivalent mechanism:
-
ClientIdentity—issue(metadata) → JWS,resolve(client_id) → Identity|nil(pure verify, no lookup) (§4.1). Identity exposessubject / client_name / redirect_uris / requested_scope. - DCR registration validation — validate
redirect_uris(RFC 7591 / 8252 §7) and rate-limit the endpoint beforeissue(§4.1, §11). -
KeyStore—active_private_key,active_kid,verification_key(kid); keys from a secret store; multi-kidrotation grace period (§4.4). -
OauthApplication.by_uid— returns aninstantiated virtual app;derived_iddeterministic, a positive 63-bit integer (§5). - The authorize-time hook —
bind_audience(honor + validateresource, canonical fallback),resolve_tenant,record_client_subject(verified JWS),record_active_session; helper contracts per §6.2. - Custom-attribute columns on both grant and token tables +
custom_access_token_attributes(§6.1). - The Active Session record — table +
authorize!/touch_seen/prune_expired/activescope; decoupled for display-only, projected/revoke-synced if it drives revocation (§9). - The call-time boundary (resource-server side) — token acceptable + audience + tenant + membership, fail closed (§3, §11).
- Cleanup — a scheduled job wrapping the native cleaner (revoked + expired, tokens and grants);
expires_innever nil;clean_revokedfrequent,clean_expiredper TTL (§8.4). - Revocation — scoped by
(tenant, owner), tokens and grants; neverrevoke_all_forin multi-tenant (§10). - Token enumeration + revoke entry — list a member's un-revoked tokens by
(tenant, owner, client_subject)and revoke by id, so a leaked token can be killed without waiting out the TTL (§11). - Rate limiting — on DCR/authorize/token endpoints (not on by default) (§11).
- Audit log — DCR registration / authorization / revocation events (§11).
- Config (initializer):
force_pkce(S256), singlegrant_flows,application_class, guardedafter_successful_authorization(§5). Routes:use_doorkeeper { controllers authorizations: "oauth/authorizations" }(thecontrollersDSL belongs in routes, not the initializer). - Mixed TTL → segmented
StaleRecordsCleaner, discriminator = audience; global TTL ≥ the largest segment (§8.5). -
revoke_previous_*left off unless the nil-application path is handled (§12.3). - If using the denylist (§16): consult it as a gate after resolution (not inside
resolve), fail closed, couple it with per-token/scoped revocation.
The following are design sketches, NOT implemented or validated. Treat them as direction, not normative guidance.
- Per-client identity revocation (a bounded-state security extension). Full statelessness gives up the single-client off switch (§4.5). Reintroduce a bounded amount of state — a small
client_subjectdenylist — as a deliberate hybrid (stateless-core identity + a bounded denylist):- Consult the denylist as a gate after signature verification (not inside
resolve, which stays a pure verify); a denied subject is treated as no client. - The lookup MUST fail closed (a denylist-store failure denies; it does not open).
- Couple it with token revocation. The denylist blocks future authorizations for a subject; already-issued tokens are killed only by revoking them by
(tenant, user, client_subject)(§10). Neither alone suffices. - A denylist entry must live at least
max(client_id exp, longest token TTL), then be reclaimed by its own TTL-based GC — fold that into the §8 reclamation story so the hybrid's state stays genuinely bounded (§8.3's closure does not otherwise cover it). - Driving it from the Active Session record (revoking a session revokes its tokens and denylists its subject) is one natural shape — and it is what a per-row revoke action in a session list (§9.2) would call. Unimplemented and unverified here; validate growth, expiry, and read-path cost before adoption.
- Consult the denylist as a gate after signature verification (not inside
- CIMD compatibility (§2.1). Accept an HTTPS-URL
client_idalongside JWS client_ids, fetching the client's metadata document, as CIMD adoption grows. The resolution dispatch (§7) is the natural seam. - Token introspection (RFC 7662). If exposed, answer consistently across client types — return
client_subjectas the client id and alignactivewith the call-time boundary, not the session view (§7).
- RFC 6749 — The OAuth 2.0 Authorization Framework
- RFC 7591 — OAuth 2.0 Dynamic Client Registration Protocol
- RFC 7636 — Proof Key for Code Exchange (PKCE)
- RFC 7662 — OAuth 2.0 Token Introspection
- RFC 8252 — OAuth 2.0 for Native Apps (redirect_uri rules, §7)
- RFC 8414 — OAuth 2.0 Authorization Server Metadata (AS discovery)
- RFC 8707 — Resource Indicators for OAuth 2.0
- RFC 9728 — OAuth 2.0 Protected Resource Metadata (the canonical resource identifier / audience)
- RFC 2119 — Key words for use in RFCs
- OAuth 2.0 Security Best Current Practice (short-lived access tokens, refresh rotation)
- Model Context Protocol — Authorization (2025-11-25 revision; DCR → MAY, CIMD direction)
- Doorkeeper 5.9.x —
application_class/by_uid,custom_access_token_attributes,custom_access_token_expires_in,StaleRecordsCleaner,rake doorkeeper:db:cleanup - Solid Queue — recurring tasks (
config/recurring.yml)
"Verified fact" notes were checked against Doorkeeper 5.9.1 source and SQLite behavior; the instantiate no-autosave behavior is an ActiveRecord property — re-verify the relevant sections against your Doorkeeper and Rails versions and your database adapter before relying on them. JWT option names follow ruby-jwt; confirm against your library.
Warning
This RFC-style document is written by Claude Code with 3-rounds multi-agents review. Only a suggestion, not a standard.