Rails Has Your Back: Security You Don't Have to Think About

Rails security defaults cover CSRF, SQL injection, XSS, encrypted sessions, and more — here's what the framework handles before you write business logic.

Last week, I presented From the Prompt to the MVP at Claude Colima, where I showed all the prep work I do before starting a Rails application using my Maquina AI tools — especially the MVP Creator. The whole point of the talk was to show how much of the setup and structure you can think through before writing a single line of code.

At the end, several people came up and asked me how I handle Ruby on Rails security when using Claude Code for the actual implementation. My answer probably disappointed them. I said: “I do Ruby on Rails, and the framework does a lot of the security work out of the box.”

I could tell that wasn’t what they expected to hear. Most of them were coming from other languages and frameworks where that answer doesn’t hold — where you have to reach for libraries, configure middleware manually, and make a lot of explicit choices just to cover the basics. The idea that a framework would handle that for you by default didn’t quite land.

But it’s true, and it’s actually one of the reasons I bet on Rails for AI-assisted coding. There’s a large amount of accumulated security knowledge baked into Rails conventions, and LLMs already have that knowledge in their training data. When Claude Code writes a Rails controller, it writes it the Rails way — and the Rails way is the safe way against many common vulnerabilities.

This is the first of two posts on security and Rails. Here, I’ll cover what the framework handles before you write a single line of business logic. The second post covers the decisions that are still yours to make — the things no framework can decide for you.

Here’s what Rails gives you for free.


SQL Injection Doesn’t Happen by Accident

ActiveRecord parameterizes your queries. When you write User.where(email: params[:email]), Rails never interpolates that value directly into SQL. It always goes through the database driver as a bound parameter.

The only way to introduce SQL injection in a modern Rails app is to do it explicitly — to reach for User.where("email = '#{params[:email]}'"). That’s a conscious choice, not an accidental one. And if you’re using an AI to write your queries, it’ll almost always reach for the safe form because that’s what the training data is full of.

# This is what Rails (and AI tools) produce by default — safe
User.where(email: params[:email])
User.where("email = ?", params[:email])

# This is the dangerous form — you have to go out of your way to write it
User.where("email = '#{params[:email]}'")

XSS Doesn’t Happen by Accident Either

ERB templates escape everything by default. The <%= %> tag encodes whatever you put into it — &, <, >, ", all of it — before it hits the browser. A <script> tag in user input is rendered as literal text on the page, not executable code.

To render raw HTML, you have to explicitly use raw() or html_safe. That’s not something Rails generates for you, and it’s not something an AI assistant reaches for unless you specifically ask for it. The safe path is the default path.


CSRF Protection is On by Default

Every new Rails application includes protect_from_forgery in ApplicationController. Every form rendered through Rails helpers includes a hidden authenticity token. Every non-GET request that comes in without a valid token gets rejected.

Since Rails 8.0, those tokens are scoped per method and action. A token generated for POST /sessions/new cannot be replayed against DELETE /sessions/1. If a token somehow leaks — through verbose logging, a cache miss, anything — the blast radius is a single endpoint.

Rails 8.2 goes further. New apps will use Sec-Fetch-Site header verification instead of token-based CSRF protection. Modern browsers send this header automatically with every request, and it’s browser-controlled — it can’t be spoofed by malicious JavaScript. The benefit is that you stop getting the false-positive 422 errors that come from cached pages with stale tokens, and JavaScript frameworks no longer need to extract tokens from <meta> tags.

# Rails 8.2 default (opt-in now, default for new apps soon)
Rails.application.config.action_controller.forgery_protection_verification_strategy = :header_only

If you’re on an older app or need to support some very old browsers, the :header_or_legacy_token strategy checks the header first and falls back to token verification. No abrupt breakage, just a migration path.


Your Session is Encrypted

Session data lives in a signed, encrypted cookie. The encryption key is derived from secret_key_base, which is generated uniquely for each application and stored in config/credentials.yml.enc. No two Rails apps share the same session key.

Session cookies are marked HttpOnly by default, which means JavaScript cannot read them. They’re marked SameSite=Lax, which means cross-site requests won’t automatically include them. In production with config.force_ssl = true, they’re also marked Secure, which means they’ll only travel over HTTPS.

None of that is something you configure. It’s just what Rails does.


The Security Headers Ship With the Framework

Open a new Rails app and make a request. Look at the response headers. You’ll see:

X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-Permitted-Cross-Domain-Policies: none
Referrer-Policy: strict-origin-when-cross-origin

X-Frame-Options: SAMEORIGIN prevents your app from being embedded in an iframe on another domain — that’s the primary mechanism behind clickjacking attacks. X-Content-Type-Options: nosniff tells the browser not to guess content types, which prevents a class of attacks where a malicious file is uploaded and then loaded as a script. Referrer-Policy limits how much information leaks to third-party sites when users follow links.

Rails 8.2 removes X-XSS-Protection from the default headers. This is intentional. That header activated the browser’s built-in XSS auditor, which all major browsers have now removed because it was unreliable and could itself be exploited to block legitimate scripts. Removing it is the right call.


Mass Assignment is Blocked at the Framework Level

The entire Rails philosophy around params.require().permit() exists to prevent mass assignment attacks — where an attacker adds extra fields to a form submission, hoping they’ll be written directly to the database. Without explicit permission at the controller level, no parameter reaches a model.

Rails 8.0 added params.expect as a stricter version of this. It validates the exact shape of the incoming parameter hash, not just which keys are allowed. Unexpected nesting, arrays where scalars were expected — all rejected.

# The old way — permits any value for :name and :email, regardless of type
params.require(:user).permit(:name, :email)

# The new way — validates the exact structure
params.expect(user: [:name, :email])

An AI tool that writes a controller will produce one of two forms. Both are safe. Neither will write unfiltered user input to your database.


Credentials Are Encrypted by Default

Database passwords, API keys, third-party secrets — in a Rails app, these live in config/credentials.yml.enc. The file is encrypted and safe to commit to version control. The decryption key lives in config/master.key, which is in .gitignore from day one.

In production, you pass the master key via an environment variable. The credentials file never contains an unencrypted secret.


SSL is Forced in Production

Since Rails 7.0, production applications come with config.force_ssl = true out of the box. Every HTTP request redirects to HTTPS. The response includes Strict-Transport-Security: max-age=31536000, which tells browsers to only contact your app over HTTPS for the next year — even if the user types in a plain http:// URL directly.


ReDoS Has a One-Second Leash

Rails 8.0 added an automatic one-second timeout on regular expression evaluation. This catches ReDoS attacks — where a maliciously crafted string causes a regex to backtrack catastrophically, pegging a CPU core and hanging the request indefinitely. The framework kills the evaluation and moves on.

You didn’t have to write that. You didn’t have to know it was a risk. It’s just there.


Rails 8 Added a Built-in Authentication Generator

Since Rails 8.0, running bin/rails generate authentication scaffolds a complete, production-appropriate authentication system: a User model with bcrypt password hashing via has_secure_password, a Session model with secure token generation, a password reset flow with time-limited tokens, and rate-limiting hooks already wired in.

This isn’t a gem you’re trusting with your auth. It’s generated code that lives in your application, built on primitives that have been in Rails for years, using bcrypt as the hasher. You can read every line of it.


What This Means for Vibe-Coded Apps

When someone asks how to ensure an AI-generated Rails app is secure, a big part of the answer is that the framework already makes it secure in ways that are very hard to accidentally undo.

An AI tool writing a Rails controller isn’t going to disable CSRF protection — it’s on by default, and there’s no reason to touch it. It’s not going to interpolate user input into SQL strings — the safe parameterized form is what the training data contains. It’s not going to skip output encoding — ERB does it for you.

The security you get for free from Rails is substantial. It covers the OWASP Top 10’s most common web vulnerabilities at the framework level.

But there’s a layer it doesn’t cover — and that layer is always on you, regardless of whether the app was written by a human, an AI, or some combination of both. That’s what the next post is about.