cd /news/developer-tools/rfc-stateless-oauth-client-identity-… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-36039] src=gist.github.com β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

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.

read35 min views1 publishedJun 21, 2026

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

andby_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 itsclient_id

.CIMDβ€” Client ID Metadata Document. An alternative where theclient_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-reusedclient_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 storesno 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 recordedclient_subject

by valueon each grant/token. It is the true client-isolation key (Β§5).derived_idβ€” an integer deterministically derived fromclient_subject

, used as the provider'sapplication_id

surrogate. It is dynamic, non-recurring, and has no backing row. It is plumbing, not an isolation key.denylistβ€” a small set ofclient_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 onrevokedsubjects (Β§16) is permitted β€” it is not a registration table and does not grow with registrations. - Grants and tokens are the SoT for whether acallis 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

beforeissuance:

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 asignedopen-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)

  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

  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 aniss

note:internalartifact, not an OAuth/OIDC token that leaves the AS's trust domain. Itsiss

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 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 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 aclient_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 thepublic 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 JWSLIFETIME

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 abusiveclient_id

can otherwise be retired only by waiting out itsexp

or rotating the signing key (which retires everyone). The Β§16 denylist extension blocksfutureauthorizations for a subject β€” but already-issued tokens stay valid until their own expiry, so itMUST be coupled with token revocation (Β§10), and revocation immediacy remains bounded by the token TTL.Key-rotation blast radius. Rotating the signing key invalidates everyclient_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 JWSclient_id

is ~300–500 bytes (ES256, base64url). It travels in authorize URLs (mind URL-length limits), redirects, and logs, and itsclient_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 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.

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
after_successful_authorization do |controller, _ctx|
  controller.send(:record_active_session) if controller.respond_to?(:record_active_session, true)
end

class OauthApplication < ApplicationRecord
  include ::Doorkeeper::Orm::ActiveRecord::Mixins::Application

  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

  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

  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 objectnot a new record(it relies onActiveRecord::Persistence.instantiate

setting@new_record = false

), so assigning it to a grant/token sets only the foreign key and doesnot autosave (autosave fires only for new records). AdoptersMUST useinstantiate

, notnew

. (This leans on an ActiveRecord behavior; re-verify acrossRails versions, not only Doorkeeper versions, and include the model's NOT-NULL columns in the hash.)derived_id

MUST be a deterministic function ofclient_subject

landing 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**the surrogate is a 63-bit hash, so two distinctderived_id

collisions:client_subject

s can in principle collide (the birthday bound applies to the distinctclient_subject

s carried byall 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 PKCEcode_verifier

is bound to the specific authorization, so a colliding client cannot redeem another's code. (PKCE coversonlythe exchange.) - Everywhere else β€” revocation, segmentation, session identityβ€” never usederived_id

as a key; PKCE does not protect those. Key them onclient_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 withgrant.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 throughapplication_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


  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

  def resolve_tenant
    params[:workspace_id] = authorized_tenant_id_for(current_resource_owner, params)
  end

  def record_client_subject
    params[:client_subject] = ClientIdentity.resolve(params[:client_id])&.subject
  end

  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'sparams

reach the grant?PreAuthorization

reads 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

/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 dispatchingby_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(<adapter-specific expiration SQL> < now).in_batches(&:delete_all)

Verified fact (Doorkeeper 5.9.1):both queries keyonlyonrevoked_at

/created_at

+expires_in

β€” theynevertouchapplication_id

.

Consequences:

  • The dynamic, non-recurring application_id

(Β§5) hasno 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. Adependent: :delete_all

cascade from the application would never help here anyway (there is no application row to cascade from), so it plays no part. application_id

isnota usable discriminator for cleanup or segmentation (per-registration noise with no backing row); useaudience

/client_subject

instead (Β§8.5).- The oauth_applications

table 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, adoptersMUST NOTissue non-expiring tokens (expires_in

always 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 boundsstorage; 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
production:
  oauth_credential_cleanup:
    class: OauthCredentialCleanupJob
    schedule: every hour

Two cadences, not one.clean_revoked

SHOULD run frequently (e.g. hourly) regardless of TTL, so a revocation is reflected in storage promptly;clean_expired

only needs to keep pace with the TTL. Donot 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 coarsecreated_at

filter (with a warning) β€” provide acustom_expiration_time_sql

there. Refresh-token caveat:clean_expired

skips rows with a non-nullrefresh_token

(they may be refreshed). If you enable refresh tokens (Β§12.2), those rows are reclaimed only byclean_revoked

or 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 - globalTTL

is keyed to a single global TTL. If the global is the longest value, short-TTL tokens linger until that window. Run asegmented cleanup:StaleRecordsCleaner

accepts 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_in

MUST 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):

class McpSession < ApplicationRecord
  RECENCY = 1.hour     # deployment-set display window

  scope :active, -> { where(last_seen_at: RECENCY.ago..) }   # shown vs hidden

  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

  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

  def self.prune_expired = where(expires_at: ..Time.current).delete_all
end

Display 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 sessionMUST 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-inrevoke_all_for(application_id, resource_owner)

keys only on(application_id, resource_owner)

β€” it hasno 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, revocationMUST be scoped to the full granularity it targets andMUST NOT userevoke_all_for

. (In a strictly single-tenant deployment the over-revoke risk does not arise;revoke_all_for

is then acceptable.) Member removal scopes by(tenant_id, resource_owner_id)

; a per-client "end session" addsclient_subject

. - Revocation MUST act onboth 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 aleaked tokencan actually be located and killed without waiting out a long TTL.

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_revoked

reclaims the row β€” aclosed loop: revoke β†’ reclaim. - Member removal SHOULD combine active revocation (above) with the call-time membership re-check asdefense in depth: even if a revoke is missed, the boundary still denies a removed member.

Audience binding (RFC 8707). RFC 8707 isclient-driven: the client sends one or moreresource

parameters, and the AS applies an audience restriction. The verifiable invariant: a providerMUST NOT unconditionally overwrite a validly-sentresource

. Validate eachresource

against 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_tenant

already 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 multipleresource

values explicitly rather than silently keeping one. The audience is expressed on the token (RFC 7519aud

/ RFC 7662) andMUST be compared at the call-time boundary.DCR redirect_uri validation. The DCR endpoint is unauthenticated; itMUST validateredirect_uris

at registration per RFC 7591 / RFC 8252 Β§7 (loopback or private-use scheme; reject non-loopbackhttp

, wildcards)before issuing aclient_id

. Skipping this issues asignedopen-redirect that PKCE does not fully contain.client_subject

fail-secure.MUST trust only thesub

from the verified JWS; a same-named value in the requestMUST be overwritten (Β§6.2).JWS is not a credential. Theclient_id

JWS appears in authorize URLs and logs and isnot a secret. It authenticates nothing about the presenter; security comes fromPKCE possession + audience + user consent. Do** nottreat 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 providerMUST**force_pkce

with S256 (RFC 7636 / RFC 8252). PKCE is also what makes aderived_id

collision non-exploitableat the token exchange(Β§5) β€” but only there.DCR endpoint abuse / DoS. Statelessness stops theclient tablefrom growing, but each issuedclient_id

can drive authorizations that create grant/token rows β€” so the DoS surfacemoves 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 donot help if the member is still valid. To kill a leaked token you must firstfindit: the decoupled session (Β§9) is client-level and carries no token id, so provide anadmin entry that enumerates a member's live tokens by(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.(tenant, owner, client_subject)

and revokes by idMember removal is immediate. RemovalMUST revoke the member's credentials (Β§10), and the boundarySHOULD independently re-check membership.Tenant is a selector, not a source. A tenant named in the requestMUST only be matched against the token's tenant, never used as its source.Fail closed. Any failed boundary checkMUST collapse to one unauthenticated outcome, not leaking whether a resource exists. A denylist (Β§16) lookup failureMUST also fail closed.Clock skew. Cleanup andexp

/expires_in

checks 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 smallleeway

onexp

verification (Β§4.1), and centralize the comparison on asingle clock source(e.g. the database'snow()

via the provider's expiration SQL). Note thiscentralizes, 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 everyclient_id

; use the multi-kid

grace period (Β§4.4) for planned rotation.Audit (a named compensating control). Discarding the client table also discards the natural registration history β€” so theonlytrace 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 owncustom_access_token_attributes

mechanism 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 needsuch an index: scoped revocation (Β§10) filters byresource_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 thereasonthe session is decoupled β€” that reason is in Β§9.2.) - The provider's default application_id

index 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 TTLMUST be β‰₯ the largest segment TTL (Β§8.5).

Verified fact (Doorkeeper 5.9.1):revoke_previous_authorization_code_token

callsrevoke_previous_tokens(grant.application, ...)

β†’application.id

with no safe-navigation; under stateless identitygrant.application

(loaded by theapplication_id

association, which has no backing row) is nil β†’nil.id

β†’ NoMethodError.

revoke_previous_authorization_code_token

andrevoke_previous_client_credentials_token

MUST NOT be enabled until the nil-application path is handled (return a virtual application for thegrant.application

association, or guard the nil).reuse_access_token

isnot 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β€” validateredirect_uris

(RFC 7591 / 8252 Β§7) and rate-limit the endpointbeforeissue

(Β§4.1, Β§11). - β€”KeyStore

active_private_key

,active_kid

,verification_key(kid)

; keys from a secret store; multi-kid

rotation grace period (Β§4.4). - β€” returns anOauthApplication.by_uid

instantiate

d virtual app;derived_id

deterministic, 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 onboth grant and token tables +custom_access_token_attributes

(Β§6.1). - The Active Session recordβ€” table +authorize!

/touch_seen

/prune_expired

/active

scope; 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_in

never nil;clean_revoked

frequent,clean_expired

per TTL (Β§8.4). - Revocationβ€” scoped by(tenant, owner)

, tokensand grants; neverrevoke_all_for

in 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" }

(thecontrollers

DSL 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 insideresolve

), 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 aboundedamount of state β€” a smallβ€” as a deliberate hybrid (stateless-core identity + a bounded denylist):client_subject

denylist- Consult the denylist as a gate after signature verification (not insideresolve

, 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 blocksfutureauthorizations 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 anddenylists 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 CIMD compatibility(Β§2.1). Accept an HTTPS-URLclient_id

alongside 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 β€” returnclient_subject

as the client id and alignactive

with 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.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @doorkeeper 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/rfc-stateless-oauth-…] indexed:0 read:35min 2026-06-21 Β· β€”