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 use
revoke_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 own
custom_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-URL
client_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.