Rails is great at making the happy path simple. You need a record, you write Model.find(params[:id]). You need an authorization check, you add a line under it. The code reads well, it feels clean, it passes review, and it's also the reason why perfectly competent teams ship IDORs.
The issue is not that people don't know they need authorization. The issue is that "fetch then check" relies on humans to remember the check every time, across every new endpoint, refactor, export job, and admin screen. One missing line is enough. If you want to kill IDORs in a Rails app, you don't start by writing more checks. You start by changing the shape of your code so the insecure version becomes harder to write.
The classic Rails footgun looks like this:
@project = Project.find(params[:id])
authorize! @project
Even if it's correct today, it's fragile. Someone adds a new action and forgets the authorize!. Someone reuses the query in a different code path and assumes "authorization happens elsewhere". Someone writes an export endpoint in a hurry. Now you have an IDOR and you're debugging why "users can see each other's stuff".
The boring fix is to stop thinking of authorization as a boolean check and start thinking of it as a dataset. Don't fetch the record and then ask "can the user see it?". Ask the database for "records the user can see", and then fetch the record inside that relation.
@project = current_user.projects.find(params[:id])
In a multi-tenant application it's the same idea:
@invoice = current_account.invoices.find(params[:id])
This changes the failure mode in a way I prefer. If the user shouldn't see it, Rails raises ActiveRecord::RecordNotFound and you're done. The record never loads, which means you're not leaking metadata in error messages, logs, or templates that accidentally touch the object. Most importantly, the authorization rule is no longer a line you might forget. It becomes the query itself.
Once you start writing code like this, another temptation appears quickly: if scoping is good, wouldn't it be better if it was automatic? That's where default_scope enters the story.
I like default_scope. It makes code feel tidy because you stop repeating yourself. Soft delete is the classic example: you add a condition once, and everything in the app "just works" without needing to remember where(deleted_at: nil).
class Product < ApplicationRecord
default_scope { where(deleted_at: nil) }
end
If you open a console and ask Rails what it would run, you can see why it feels so nice:
> Product.all.to_sql
SELECT "products".*
FROM "products"
WHERE "products"."deleted_at" IS NULL
That's clean, consistent, and boring. The problem is that default scopes are quiet. They sit in the background, and they stack over time. You might start with soft delete, then later you add something else, and later you add something else again. At some point you'll need to bypass one of these defaults, usually for admin/reporting/export code, and that's where teams make a mistake that looks harmless in Ruby but is terrifying in SQL.
Rails gives you unscoped, which removes default scopes. The name sounds reasonable. You might even read it as "remove the default thing I don't want right now". But that's not what it means. unscoped is not selective. It drops everything.
To see why this becomes a security issue, let's look at a real app situation: multi-tenancy plus soft delete. People often end up with both concerns expressed as default scopes, sometimes directly, sometimes via concerns.
class Product < ApplicationRecord
default_scope { where(account_id: Current.account.id) } # tenant boundary
default_scope { where(deleted_at: nil) } # soft delete
end
Now the default query looks like what you want:
> Product.all.to_sql
SELECT "products".*
FROM "products"
WHERE "products"."account_id" = 42
AND "products"."deleted_at" IS NULL
So far, everything is fine. Then someone writes an admin page that needs to include deleted products. The intention is reasonable: "same tenant, but include deleted". A lot of people reach for unscoped to do that.
> Product.unscoped.to_sql
SELECT "products".*
FROM "products"
And that's the footgun in one screenshot. They wanted to remove deleted_at IS NULL. They also removed the tenant boundary. If you're not careful, that's how "admin can see deleted products" turns into "admin can see everyone's products", or worse, "any code path that calls this now mixes tenants and leaks data". It's not that the developer was reckless; it's that the tool was too blunt for what they were trying to do.
This is why I don't like seeing unscoped in application code. It doesn't just remove the one default you're annoyed by. It removes the defaults you forgot existed, and the defaults someone will add in six months. It ages badly.
Rails also gives you unscope, which is the scalpel. It lets you remove exactly the part of the query you want to remove and keep everything else intact. If the only thing you want is "include deleted", then say that explicitly:
> Product.unscope(where: :deleted_at).to_sql
SELECT "products".*
FROM "products"
WHERE "products"."account_id" = 42
That SQL is what the developer meant. It removes soft delete and preserves the tenant boundary. It does not rely on "nobody ever adds another default scope later". It is boring in exactly the right way.
Once you've seen those two queries side-by-side, it becomes hard to justify unscoped for this kind of bypass. If your intention is specific, your query should be specific too.
There's a variant that I dislike even more because it looks safe at a glance:
> Account.first.products.unscoped
Your brain sees Account.first.products and relaxes. It feels like we're scoped to an account. Then unscoped shows up at the end, and people mentally file it as "fine, we're just removing the soft-delete thing".
But unscoped doesn't mean "remove soft delete". It means "remove all default scopes on Product". If your tenant boundary lives in a default scope (for example through a Current.account pattern), you're now one method call away from returning products outside the boundary you thought you had. Even in cases where the association constraint still applies, the intent is unclear and the blast radius is too big for something that usually started as "I just want to include deleted".
If what you want is "products for this account, including deleted ones", then write exactly that:
> Account.first.products.unscope(where: :deleted_at)
That one line makes code review simpler because the intent is obvious, and it makes the SQL predictable because the bypass is surgical.
If your codebase uses soft delete and you keep it as a default scope, I don't love sprinkling unscope(where: :deleted_at) everywhere. It's correct, but it's also repetitive. I'd rather see a named scope or class method, because it reads like intent and it gives you one place to test and review.
class Product < ApplicationRecord
default_scope { where(account_id: Current.account.id) }
default_scope { where(deleted_at: nil) }
scope :with_deleted, -> { unscope(where: :deleted_at) }
end
Now Product.with_deleted is self-explanatory, and so is the SQL:
> Product.with_deleted.to_sql
SELECT "products".*
FROM "products"
WHERE "products"."account_id" = 42
When you see a PR using with_deleted, you can immediately tell what's happening. When you see a PR using unscoped, you have to ask what else got removed and whether it will still be safe after the next "helpful" default scope is added.
IDORs happen when access control becomes "a thing you remember to do". Scoping is one of the simplest ways to turn authorization into structure instead of ceremony, because it forces your queries to operate on an authorized dataset by default.
Default scopes can help with that feeling of "safe by default", but only if you treat bypasses with the same discipline. The moment you use unscoped casually, you're back to relying on human memory and assumptions, except now the assumptions are hidden inside ActiveRecord relations.
My rule in reviews is boring and consistent: if you're bypassing something specific, use unscope to bypass something specific. If you use unscoped, you're not bypassing one thing; you're dropping all the constraints you forgot you had, or may have in the future.
When doing code review, you care about code being secure and staying secure. Pushing for scoped queries and flagging calls to unscoped helps ensure the code doesn't silently break six months from now when someone adds a new default scope or refactors the model.
Named scopes like with_deleted have another advantage: they give you a single place to update. If the column name changes from deleted_at to archived_at, or you need to add another condition, you change it once and every caller gets the fix. Scattered unscope(where: :deleted_at) calls across twenty files means twenty places to forget.
Rails makes it easy to write Ruby that reads like you meant the right thing. The database doesn't care what you meant. It only cares about the query you actually sent.
If you use AI coding assistants, consider adding these rules to your AGENTS.md or CLAUDE.md. The same principles that make code easier for humans to review also make it harder for AI to write insecure code by default:
## Rails Authorization
- Prefer `current_user.projects.find(params[:id])` over `@project = Project.find(params[:id]); authorize(@project)` — authorization should be part of the query, not a separate check
- Avoid `unscoped`; use `unscope(where: :column)` to bypass specific default scopes without removing others
- Use named scopes for common bypasses (e.g., `scope :with_deleted, -> { unscope(where: :deleted_at) }`) so there's one place to update
This won't catch everything, but it shifts the default. Instead of the AI generating the classic footgun and hoping you notice during review, it generates the scoped version first. Same idea as the rest of this post: make the secure path the easy path.