Writing
Doorkeeper's most dangerous defaults are the ones you didn't set
September 7, 2026 · Amit Solanki
How many lines of configuration stand between bundle add doorkeeper and a fully open
/oauth/applications admin panel on the public internet?
Zero. Not a few missing ones - zero. The option that would have prevented it isn’t in the generated file at all. There’s a comment showing you the syntax, in case you go looking.
In the last post I mentioned two of these in passing: an unset admin_authenticator
that fails open instead of locked, and unset secret-hashing options that store everything in
plaintext. I should be as plain here about how they were found as I was there. I didn’t find
them. Claude did, reviewing the initializer at the end of the build - the same session that also
produced a dynamic-registration endpoint no real client could use.
That isn’t a disclaimer on the finding, it’s the argument. Every default in this post is written down somewhere: in Doorkeeper’s source, sometimes in the generated file’s own comments. Finding them is a retrieval problem, and on retrieval a model beats a tired human reading an initializer at the end of a long build.
But the four aren’t one story, and flattening them into one would be dishonest. Two were caught in that review. One I found later, during hardening, by going back to check a config value nothing had complained about. One I got right on my own, deliberately, by reading what the default actually permitted before accepting it. That mix is the real shape of building this way.
The throughline is still the generator: Doorkeeper’s is optimized for “get a working demo running,” not “make a production security decision on purpose.” Every option below is either absent from the generated file, or present with a default value nobody asked for.
1. admin_authenticator - commented out means unlocked, not locked
rails g doorkeeper:install gives you a Doorkeeper.configure block with admin_authenticator
present only as a commented-out example. It reads like an opt-in security feature you can add
later. It’s the opposite: Doorkeeper’s real default authenticator, the one that runs when you
leave this alone, is a no-op proc. Not “deny by default” - no check executes at all.
Mount use_doorkeeper in your routes and /oauth/applications is live: a full CRUD interface
over every OAuth client the server has ever registered, including the ability to view, edit and
delete them, reachable by anyone who finds the URL. No error, no redirect, no login form. In my
case this was caught in review before the app was ever deployed, so it never shipped that way.
Nothing in the generated file would have stopped it if it had.
admin_authenticator do
current_admin_user || begin
store_location_for(:admin_user, request.original_url)
redirect_to spree_admin_login_path
end
end
Four lines, the same pattern as every other admin-gated surface in the app. The fix isn’t interesting.
What’s interesting is that it surprised me at all. Most gems in this ecosystem fail closed. Define no abilities in CanCan and it denies everything. Rails ships CSRF protection and strong parameters switched on and makes you opt out of them. Two decades of that convention trains an expectation you stop noticing you have: the unset state is the safe state, and if I’ve forgotten something that matters, the app will tell me by breaking.
Doorkeeper inverts that here, and the reason it stings more than a normal unguarded controller
is that you didn’t build this surface - the gem mounted it for you. Forgetting to protect an
admin panel you wrote yourself is an ordinary mistake with an ordinary cause. Here, one line in
routes.rb conjures a live CRUD interface over your OAuth clients, and the option that would
have guarded it isn’t a default you overrode. It’s a comment.
2. hash_application_secrets / hash_token_secrets - unset means plaintext, not “reasonable default”
Same shape of trap, different consequence. Neither of these appears in the generated initializer
except as a comment. Leave them alone and Doorkeeper’s real default is
Doorkeeper::SecretStoring::Plain - every oauth_applications.secret, every
oauth_access_tokens.token, every refresh_token, stored in the database exactly as issued. In
an app where user passwords go through bcrypt via Devise two tables away, the OAuth credentials
would have sat next to them in plaintext.
hash_application_secrets
hash_token_secrets
Two lines. Verified live after adding them: the stored secret column no longer matches the
plaintext value you’d get back from the API - Doorkeeper hashes on write and compares hashes on
lookup, so nothing else in the flow changes. There was nothing to weigh here - the fix has zero
behavioral cost. The only cost was not knowing to look.
3. use_refresh_token - off by default, and the failure is silent
This one didn’t come from the review. It surfaced later, during hardening, and not because
anything broke: I went looking at the live config and found access tokens expiring after
Doorkeeper’s default 7200 seconds with refresh_token_enabled? returning false.
Nothing had gone wrong yet, and nothing was going to tell me. That’s the part worth writing down. Every manual test of the connector finished inside two hours, which means the entire test loop was shorter than the interval at which the thing fails. A connected client - claude.ai’s MCP connector, here - would have worked perfectly, then started failing with no error pointing anywhere near the cause, forcing the admin back through a full re-consent flow on a schedule nobody set on purpose.
use_refresh_token
One line fixes the mechanism, but it’s not one line of work - setting it changes what
Doorkeeper both accepts and advertises, and two other places in this exact codebase claim to
know the server’s grant types independently: the RFC 8414 discovery document
(well_known_controller.rb’s grant_types_supported) and the RFC 7591 dynamic-registration
endpoint’s own SUPPORTED_GRANT_TYPES list. Doorkeeper updates its own side when you set the
flag; those other two are mine to keep in sync by hand, and nothing enforces it. Get one wrong
and a client is either told it can’t refresh when it can, or told it can and then rejected when
it tries. I left comments in all three pointing at each other, so the next change to one is a
three-file diff rather than a one-file diff with two silent regressions.
Verified live, not assumed: refresh_token_enabled? returns true, both discovery documents
advertise refresh_token as a supported grant, and a newly issued token actually carries one.
4. grant_flows - the default includes a flow that skips consent entirely
This is the one that never made it into the last post, and it’s the one I’d flag first if I were reading this instead of writing it.
Doorkeeper’s default grant_flows is ["authorization_code", "client_credentials"]. Client
credentials is a real, spec-legal OAuth flow: a client presents its client_id and
client_secret straight to the token endpoint and gets an access token back. No redirect, no
login screen, no admin clicking “Allow.” For service-to-service auth with no human in the loop
to consent on anyone’s behalf, it is exactly right.
Here it would have been a hole, and not because the flow is bad. Because of what it does to a different decision elsewhere in the same app.
This server has an open dynamic-registration endpoint (RFC 7591). Anyone can register a client, unauthenticated. That is deliberate, and the reason it is safe is one specific argument, which I wrote into the file at the time: registering a client is cheap and grants it nothing by itself, because every token still requires an admin to log in and approve it on the consent screen.
client_credentials invalidates that argument completely. A registrant that doesn’t declare
itself public gets issued a client_secret in the registration response. With client credentials
enabled, that secret is sufficient on its own - exchange it at the token endpoint, receive a
token scoped to live revenue and order data, and no human is ever involved. Open registration
stops being cheap and becomes a self-service key dispenser.
grant_flows %w[authorization_code]
Both defaults are individually defensible. Doorkeeper is right that client credentials is a legitimate flow. RFC 7591 is right that open registration is the intended model. They are only dangerous together, and nothing in either place mentions the other.
This one I did catch myself, while writing the initializer the first time, by reading what the default flow list actually permitted instead of assuming “default” means “safe.”
The pattern, stated once
Four defaults, four different consequences - an open admin UI, plaintext credentials, silent token death, and a consent bypass - and every one of them shares the same shape: a generator that optimizes for “works in five minutes” quietly makes a security decision on your behalf, and shows you the safer alternative only as a comment you have to already know to uncomment.
Audit the options you didn’t set, not just the ones you did. A config value you actively chose is a decision you already thought about, right or wrong. A config value you left at whatever the generator shipped is a decision made by whoever wrote the generator, for a use case that almost certainly wasn’t yours.
The checklist
If you’re standing up Doorkeeper for anything that isn’t a throwaway demo:
- Set
admin_authenticator. The commented-out default is a silent no-op, not a lockout. - Set
hash_application_secretsandhash_token_secrets. The real default is plaintext. - Set
use_refresh_tokenif tokens need to outlive a couple of hours - and keep your RFC 8414 discovery document and RFC 7591 registration endpoint in step with it by hand, because nothing else will. - Read
grant_flows’s actual default before accepting it.client_credentialsskips the resource-owner step entirely - right for service-to-service auth, wrong for anything gating access to one person’s data. - More generally: for every gem that ships a generator, read the generated file end to end at least once, specifically looking for what’s commented out or defaulted rather than what’s configured. That’s where the decisions you didn’t know you were making live.
The four came to me four different ways, and that’s the part I’d keep if I forgot everything else. Two came from a model reading an initializer more carefully than I was going to at the end of a long build. One came from going back to check a config value that nothing had complained about. One came from reading a default before accepting it. None of them came from the thing I would have told you I was relying on. The tests passed the whole time. The app worked the whole time. That is what makes this category of bug expensive: it is silent by construction, and the decision was made for you before you started.
None of this is exotic. It’s four Doorkeeper.configure lines and one hard-won cross-referencing
habit. The hard part was never writing the fix - it was knowing there was a decision to make in
the first place.
The platform behind this post is written up in the case study - Square POS as the source of truth, two courier networks quoting head-to-head, five published gems.
If you’re standing up an OAuth surface on Doorkeeper and want someone to audit the options you didn’t set, reach out - [email protected] or @amitkssolanki on GitHub.
Working through something similar?