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.

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:

  1. Set admin_authenticator. The commented-out default is a silent no-op, not a lockout.
  2. Set hash_application_secrets and hash_token_secrets. The real default is plaintext.
  3. Set use_refresh_token if 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.
  4. Read grant_flows’s actual default before accepting it. client_credentials skips the resource-owner step entirely - right for service-to-service auth, wrong for anything gating access to one person’s data.
  5. 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?

← All writing