Hey folks 👋
Welcome back. In Part 2 we laid the foundation: a Rails 8 API with a User model, password hashing through Devise, OAuth2 password grant via Doorkeeper, JWT access tokens, refresh tokens, and HttpOnly cookie storage. Solid base, but no actual endpoints yet.
Today we fix that. We are going to write the auth controllers (register, login, logout, refresh, and me), and while we do it we'll knock out four more vectors from the tracker: CSRF, User Enumeration, Mass Assignment, and Excessive Data Exposure. We'll also add rate limiting, encrypted DB fields, secure HTTP headers, and structured logging.
Heads up before we start: this part is longer than Part 2. I thought about splitting it again, but everything here belongs together. Controllers without rate limiting are half-protected, and rate limiting without controllers to protect is pointless. So grab a coffee and let's go.
What we are building in Part 3
- Pundit for authorization, Rack-CORS to control who can talk to our API
- A versioned API structure (
/api/v1/...) so we don't paint ourselves into a corner later - Auth controllers: register, login, logout, refresh, and a
meendpoint - Rate limiting with Rack-Attack to slow down brute force attempts
- Encrypted DB fields with Lockbox for sensitive data
- HTTP security headers with secure_headers
- Structured logs with Lograge
If your Part 2 project is sitting on your disk, open it up and let's continue.
Step 1. Add Pundit and Rack-CORS
Pundit handles authorization (the "what is this user allowed to do" question, as opposed to "who is this user" which Devise already answered). Rack-CORS controls which domains are allowed to make requests to our API from a browser.
Open your Gemfile:
# Authentication & Authorization# ...gem'pundit'# Securitygem'rack-cors'Then:
bundle install
bin/rails g pundit:install
The generator creates app/policies/application_policy.rb, which we'll use later when we add real resources.
Step 2. Configure CORS
Open config/initializers/cors.rb and replace it with:
# frozen_string_literal: trueRails.application.config.middleware.insert_before0,Rack::Corsdoallowdo# In production, replace with your real frontend domainoriginsENV.fetch("FRONTEND_URL","http://localhost:5173")resource"*",headers: :any,methods: %i[get post put patch delete options head],credentials: true,expose: %w[X-CSRF-Token]endendA mistake I have personally made and seen a hundred times in code reviews: don't use origins '*' with credentials: true. Browsers will reject it outright, and even if they didn't, it would mean any website on the internet could make authenticated requests to your API. Always pin the origin to your real frontend domain (or domains, you can pass an array).
The credentials: true part is required because our auth lives in cookies. Without it, the browser won't attach the session cookie to cross-origin requests, and login will appear to "work" but every following request will look anonymous. I spent an embarrassing afternoon on that one.
🛡️ Mitigation in action: CSRF foundation (Part 1, vector 3)
Locking down origins is the first half of CSRF defense. Combined with SameSite=Lax from Part 2, a random attacker site can't trick a logged-in user's browser into hitting our API. We'll add explicit CSRF tokens later in this post for the parts that need them.
Step 3. Update ApplicationController
ApplicationController is the entry point for every request. Everything else inherits from it. We need it to do three things: include cookie support, plug in Pundit, and require a valid Doorkeeper token by default (so endpoints are private unless we explicitly say otherwise).
Edit app/controllers/application_controller.rb:
# frozen_string_literal: trueclassApplicationController<ActionController::APIincludeActionController::CookiesincludePundit::Authorizationbefore_action:doorkeeper_authorize!,unless: :skip_authorization?privatedefcurrent_userreturn@current_userifdefined?(@current_user)@current_user=User.find_by(id: doorkeeper_token.resource_owner_id)ifdoorkeeper_tokenenddefskip_authorization?falseendendThe skip_authorization? method defaults to false, meaning every endpoint requires a token. Individual controllers (like login and register, which obviously can't require you to already be logged in) will override it to return true for specific actions. I prefer this "deny by default" pattern because forgetting to add auth is a much more common bug than forgetting to mark something public.
One thing I want to call out: current_user looks up the user from doorkeeper_token.resource_owner_id. That resource_owner_id was set back in Part 2 when Doorkeeper issued the token. The JWT itself carries the user ID, so we are NOT hitting the database to verify the token, only to load the user record. That's the whole point of JWT.
Step 4. Create the versioned BaseController
Eventually you will want to release a v2 of your API without breaking the v1 clients that are already in the wild. There are several ways to version an API (custom headers, content negotiation, URL paths). I've used all three and I'll save you the suspense: URL path versioning (/api/v1/...) is the easiest to debug, the easiest to document, and the easiest for new teammates to understand. We're going with that.
Create the folder structure:
mkdir-p app/controllers/api/v1
Then create app/controllers/api/v1/base_controller.rb:
# frozen_string_literal: truemoduleApimoduleV1classBaseController<ApplicationControllerrescue_fromActiveRecord::RecordNotFound,with: :not_foundrescue_fromActiveRecord::RecordInvalid,with: :unprocessable_entityrescue_fromPundit::NotAuthorizedError,with: :forbiddenprivatedefnot_foundrenderjson: {error: "Not found"},status: :not_foundenddefunprocessable_entity(exception)renderjson: {errors: exception.record.errors.full_messages},status: :unprocessable_entityenddefforbiddenrenderjson: {error: "Access denied"},status: :forbiddenendendendendEvery controller from now on inherits from BaseController, which means every controller gets these three error handlers for free.
🛡️ Mitigation in action: Verbose Error Messages (Part 1, vector 11)
Notice that forbidden returns a generic "Access denied" message. It does NOT say "you don't own this record" or "your role is missing the admin scope". That detail is gold for an attacker probing your API. Same idea for not_found: we just say "Not found", we don't leak whether the record exists but is private, or doesn't exist at all.
We'll fully button up production error handling in Part 4, but this is the start.
Step 5. SessionsController (login, logout, refresh)
Now the fun part. The sessions controller handles login, logout, and refresh. I'm going to paste the whole thing and then walk through the parts that matter.
Create app/controllers/api/v1/sessions_controller.rb:
# frozen_string_literal: truemoduleApimoduleV1classSessionsController<BaseControllerskip_before_action:doorkeeper_authorize!,only: %i[create refresh]# POST /api/v1/auth/logindefcreateuser=User.find_for_database_authentication(email: params[:email])ifuser&.valid_password?(params[:password])tokens=generate_tokens(user)set_auth_cookies(tokens)renderjson: {user: user_response(user),expires_at: tokens[:expires_at]}elserenderjson: {error: "Invalid credentials"},status: :unauthorizedendend# DELETE /api/v1/auth/logoutdefdestroyrevoke_tokensclear_auth_cookiesrenderjson: {message: "Logged out successfully"}end# POST /api/v1/auth/refreshdefrefreshrefresh_token=cookies.encrypted[:refresh_token]returnrenderjson: {error: "No refresh token"},status: :unauthorizedifrefresh_token.blank?existing=Doorkeeper::AccessToken.by_refresh_token(refresh_token)ifexisting.nil?||existing.revoked?||refresh_expired?(existing)clear_auth_cookiesreturnrenderjson: {error: "Expired session"},status: :unauthorizedenduser=User.find_by(id: existing.resource_owner_id)existing.revoketokens=generate_tokens(user)set_auth_cookies(tokens)renderjson: {user: user_response(user),expires_at: tokens[:expires_at]}endprivatedefskip_authorization?action_name.in?(%w[create refresh])enddefgenerate_tokens(user)token=Doorkeeper::AccessToken.create!(resource_owner_id: user.id,expires_in: Doorkeeper.configuration.access_token_expires_in,scopes: "read write",use_refresh_token: true){access_token: token.token,refresh_token: token.refresh_token,expires_at: token.expires_in.seconds.from_now.iso8601}enddefset_auth_cookies(tokens)cookie_opts={httponly: true,secure: Rails.env.production?,same_site: :lax}cookies.encrypted[:access_token]=cookie_opts.merge(value: tokens[:access_token],expires: 15.minutes.from_now)cookies.encrypted[:refresh_token]=cookie_opts.merge(value: tokens[:refresh_token],expires: 7.days.from_now)enddefclear_auth_cookiescookies.delete(:access_token)cookies.delete(:refresh_token)enddefrevoke_tokenstoken=Doorkeeper::AccessToken.by_refresh_token(cookies.encrypted[:refresh_token])token&.revokeenddefrefresh_expired?(token)token.created_at+7.days<Time.currentenddefuser_response(user){id: user.id,email: user.email}endendendendA few things worth pausing on.
The login response is intentionally vague. When credentials are wrong, we return "Invalid credentials". Not "no user with that email", not "wrong password". Both of those leak information. The first time I built an API I had a friendly "Email not found, would you like to sign up?" message. That message is also a free oracle for an attacker to verify whether victim@gmail.com has an account on your platform. Don't help them.
🛡️ Mitigation in action: User Enumeration (Part 1, vector 5)
Generic auth errors are the single cheapest mitigation in this whole series. Cost: zero. Benefit: attackers can't build a list of valid emails by hitting your login endpoint.
Refresh tokens are rotated. Look at the refresh action: when a refresh token is used, we immediately call existing.revoke on it before issuing a new one. This means a refresh token is single-use. If an attacker manages to steal a refresh token from your cookies and uses it, the legitimate user's next refresh will fail (because the attacker already burned it), and you can detect the anomaly. We're not implementing the detection part today, but the rotation alone already raises the cost of an attack significantly.
Logout actually revokes the token. A surprising number of "logout" endpoints I have reviewed in real production code just delete the cookie. That works for the honest user, but if anyone has copied the JWT before logout, it's still valid until it expires naturally. Calling token.revoke puts it on the revocation list so it can't be used again, no matter who has it.
🛡️ Mitigation in action: Token Theft (Part 1, vector 10)
Refresh rotation plus revocation on logout closes the last gap from Part 2. Now if a token leaks, we have a way to kill it.
Step 6. RegistrationsController
Create app/controllers/api/v1/registrations_controller.rb:
# frozen_string_literal: truemoduleApimoduleV1classRegistrationsController<BaseControllerskip_before_action:doorkeeper_authorize!defcreateuser=User.new(user_params)ifuser.saverenderjson: {message: "Account created successfully"},status: :createdelserenderjson: {errors: user.errors.full_messages},status: :unprocessable_entityendendprivatedefskip_authorization?trueenddefuser_paramsparams.require(:user).permit(:email,:password,:password_confirmation)endendendendThe interesting line is user_params. It explicitly lists which fields are allowed: email, password, password_confirmation. Nothing else. If a clever user POSTs { "user": { "email": "...", "password": "...", "admin": true, "role": "superuser" } }, those extra fields are silently dropped.
🛡️ Mitigation in action: Mass Assignment (Part 1, vector 7)
This is the OWASP "mass assignment" vector. Strong params is Rails' built-in fix. The rule I follow: every controller that takes user input has a *_params method, and that method whitelists only the fields it expects. Never use params[:user] directly to build a record. Ever.
Notice also that on success we don't echo back the user's data. Just a message. That keeps us from accidentally returning fields we never meant to expose.
🛡️ Mitigation in action: Excessive Data Exposure (Part 1, vector 8)
Same idea on the way out. We'll formalize this with serializers later (probably alba because it's fast and dependency-free), but for now the rule is: build the response hash by hand, listing exactly which fields go out.
Step 7. UsersController and the me endpoint
The frontend needs a way to ask "am I logged in, and if so, who am I?" on page load. That's the classic me endpoint.
Create app/controllers/api/v1/users_controller.rb:
# frozen_string_literal: truemoduleApimoduleV1classUsersController<BaseController# GET /api/v1/medefmerenderjson: {user: user_response(current_user)},status: :okendprivatedefuser_response(user){id: user.id,email: user.email,created_at: user.created_at.iso8601}endendendendSame pattern as before: hand-built response hash, only the fields we want to expose. encrypted_password, sign_in_count, reset_password_token, none of that should leak out, and with this pattern it can't.
Step 8. Wire up the routes
Open config/routes.rb:
Rails.application.routes.drawdouse_doorkeeperdoskip_controllers:authorizations,:applications,:authorized_applications,:tokensendnamespace:apidonamespace:v1dopost'auth/register',to: 'registrations#create'post'auth/login',to: 'sessions#create'delete'auth/logout',to: 'sessions#destroy'post'auth/refresh',to: 'sessions#refresh'get'me',to: 'users#me'endendget"up"=>"rails/health#show",as: :rails_health_checkendThe skip_controllers block on Doorkeeper is important. Out of the box, Doorkeeper mounts a bunch of OAuth2 endpoints (authorization page, application management, etc) that are designed for the browser-based OAuth flow. We don't need any of that, so we strip it out. Less code on the public internet means less attack surface.
Here's the API at a glance:
| Method | Endpoint | Description | Auth required |
|---|---|---|---|
| POST | /api/v1/auth/register | Create new account | No |
| POST | /api/v1/auth/login | Login, sets cookies | No |
| DELETE | /api/v1/auth/logout | Revoke tokens, clear cookies | Yes |
| POST | /api/v1/auth/refresh | Refresh access token | Cookie only |
| GET | /api/v1/me | Current user data | Yes |
Step 9. Test with curl
The trick when testing cookie-based auth with curl is the -c and -b flags. -c cookies.txt saves the cookies the server returns, -b cookies.txt sends them on the next request. It's basically what the browser does for you.
Register:
curl -X POST http://localhost:3000/api/v1/auth/register \-H"Content-Type: application/json"\-d'{"user": {"email": "me@example.com", "password": "s3cr3tP@ss", "password_confirmation": "s3cr3tP@ss"}}'Login (saves cookies):
curl -X POST http://localhost:3000/api/v1/auth/login \-H"Content-Type: application/json"\-c cookies.txt \-d'{"email": "me@example.com", "password": "s3cr3tP@ss"}'Me (protected, sends cookies):
curl http://localhost:3000/api/v1/me -b cookies.txt
Refresh:
curl -X POST http://localhost:3000/api/v1/auth/refresh \-b cookies.txt -c cookies.txt
Logout:
curl -X DELETE http://localhost:3000/api/v1/auth/logout -b cookies.txt
If all five of those work, your auth flow is alive. But we're not done. An attacker right now can hit /login ten thousand times per second with different passwords. Let's fix that.
Step 10. Rate limiting with Rack-Attack
Rack-Attack sits in the middleware stack and inspects every request before it reaches your controllers. It can throttle, block, or safelist IPs based on rules you define. It's stupidly simple to set up and saves you from a lot of pain.
Add it to your Gemfile:
# Security# ...gem'rack-attack'Then bundle install.
Create config/initializers/rack_attack.rb:
# frozen_string_literal: trueclassRack::Attack# Throttle login attempts by IP: max 5 per 20 secondsthrottle('logins/ip',limit: 5,period: 20.seconds)do|req|req.ipifreq.path=='/api/v1/auth/login'&&req.post?end# Throttle login attempts by email: max 5 per 20 secondsthrottle('logins/email',limit: 5,period: 20.seconds)do|req|ifreq.path=='/api/v1/auth/login'&&req.post?req.params['email'].to_s.downcase.gsub(/\s+/,"")endend# General API limit: 300 requests per IP per 5 minutesthrottle('api/ip',limit: 300,period: 5.minutes)do|req|req.ipifreq.path.start_with?('/api')end# Custom response when throttledself.throttled_responder=lambdado|req|retry_after=(req.env["rack.attack.match_data"]||{})[:period][429,{'Content-Type'=>'application/json','Retry-After'=>retry_after.to_s},[{error: "Too many requests. Please slow down."}.to_json]]endendWhy throttle by both IP and email? An attacker with a botnet can rotate through thousands of IPs, and IP-only throttling won't catch them because each IP only sends a few requests. But all those requests are still targeting the same email, so the email-based throttle does catch them. The reverse is also true: a single attacker hammering many different accounts from one IP gets caught by the IP throttle. The two rules together cover both attack shapes.
🛡️ Mitigation in action: Brute Force (Part 1, vector 4)
Combined with bcrypt's slowness from Part 2, this makes online brute force impractical. Five attempts per 20 seconds means an attacker can try maybe 15 passwords per minute, per email. At that rate, even a weak password takes years to crack.
One gotcha I learned the hard way: in development, Rack-Attack defaults to using the memory store, which resets every time you restart the server. That's fine. But in production you almost certainly want to point it at Redis, otherwise each app server has its own counter and an attacker can bypass the limit by spreading requests across servers. I'll show that config when we deploy in a later part.
Step 11. HTTP security headers with secure_headers
The browser will enforce a bunch of security policies for you, but only if you tell it to. The secure_headers gem makes that easy.
Add it to the Gemfile:
gem'secure_headers'bundle install, then create config/initializers/secure_headers.rb:
SecureHeaders::Configuration.defaultdo|config|# Force HTTPS for 1 yearconfig.hsts="max-age=31536000; includeSubDomains"# Restrict what resources can loadconfig.csp={default_src: %w['self'],script_src: %w['self'],connect_src: %w['self']}# Prevent your site from being framed (clickjacking)config.x_frame_options="DENY"# Stop MIME type sniffingconfig.x_content_type_options="nosniff"# Legacy XSS filter (still useful for older browsers)config.x_xss_protection="1; mode=block"# Don't leak full URLs in the Referer headerconfig.referrer_policy="strict-origin-when-cross-origin"endQuick tour of what each header does:
- HSTS tells browsers "for the next year, never talk to this domain over plain HTTP". This shuts down SSL stripping attacks, where an attacker on the same Wi-Fi downgrades your connection to HTTP.
- X-Frame-Options: DENY stops other sites from loading yours in an iframe, which is the foundation of clickjacking attacks.
- X-Content-Type-Options: nosniff stops browsers from guessing the content type of a response, which has historically been used to execute scripts that were uploaded as "images".
- CSP is the big one. It tells the browser exactly which sources of scripts, styles, and connections are allowed. Our config here is very strict ("self" only), which is appropriate for a pure API. If you're serving HTML too, you'll need to relax it.
🛡️ Mitigation in action: MITM (Part 1, vector 9)
HSTS plus secure: true on cookies (set in Part 2) is the one-two punch against MITM attacks. Combined with force_ssl in production (coming in Part 4), there's no way for an attacker on your network to downgrade the connection.
Step 12. Encrypt sensitive fields with Lockbox
bcrypt protects passwords because it's a one-way hash, you never need to read the original. But what about fields you DO need to read, like phone numbers, addresses, or government IDs? You can't hash those. You need to encrypt them, so the database stores ciphertext and the app decrypts it in memory when needed.
That's what Lockbox does.
Add to the Gemfile:
# Encryptiongem'lockbox'bundle install. Then generate a master key:
bin/rails runner "puts Lockbox.generate_key"Take the output and store it in your Rails credentials (bin/rails credentials:edit):
lockbox_master_key:<paste the generated key here>Create config/initializers/lockbox.rb:
Lockbox.master_key=Rails.application.credentials.lockbox_master_keyNow in any model, you can mark fields as encrypted. For example, if we added a phone column to User:
classUser<ApplicationRecordencrypts:phone# If you need to search by encrypted field, use blind_indexblind_index:phoneendThe migration would look like:
bin/rails generate migration AddEncryptedPhoneToUsers
add_column:users,:phone_ciphertext,:textadd_column:users,:phone_bidx,:text# for blind_index searchingThen bin/rails db:migrate.
encrypts :phone means the value is encrypted before INSERT and decrypted on read. The database column is literally named phone_ciphertext and contains base64 ciphertext. If someone dumps your database, they see nothing.
blind_index is a clever workaround for the obvious problem with encrypted fields: you can't search them, because two encryptions of the same value produce different ciphertext. The blind index is a deterministic hash of the value, stored separately, which you can search on. It's not as private as the encryption itself, but it lets User.where(phone: "...") actually find records.
I'm not adding a phone field in this tutorial because we don't need one yet, but I wanted to show the pattern so you can apply it when you do add sensitive fields. Personal rule of thumb: any field that would be embarrassing or harmful if it leaked, encrypt it.
Step 13. Structured logs with Lograge
Rails' default log format is fine for development but painful in production. You get six lines per request, none of them parseable as structured data, and no consistent fields. Lograge condenses each request to a single structured line that you can ship to Datadog, Loki, CloudWatch, or whatever you use.
Add to the Gemfile:
# Monitoring & Logginggem'lograge'bundle install. Then create config/initializers/lograge.rb:
Rails.application.configuredoconfig.lograge.enabled=trueconfig.lograge.custom_options=lambdado|event|{time: Time.current.iso8601,host: event.payload[:host],user_id: event.payload[:user_id]}endendTo get user_id into logs, you'll want to append it to the payload from ApplicationController:
defappend_info_to_payload(payload)superpayload[:host]=request.hostpayload[:user_id]=current_user&.idifdoorkeeper_tokenendThis is small but huge when you're debugging at 2 AM and trying to figure out which user's session caused that 500 error.
Where we are right now
Five working auth endpoints, rate limiting, encrypted DB fields ready when you need them, hardened HTTP headers, and structured logs. The API is genuinely usable now, not just configured.
But we still have gaps. Most notably, we haven't done explicit CSRF tokens yet (the SameSite cookie helps but isn't enough on its own for state-changing endpoints), and we haven't tackled IDOR (insecure direct object references) which only becomes a problem once we have resources to reference. Both of those plus production error handling, force_ssl, and serializers are coming up.
Progress tracker: security vectors from Part 1
| # | Attack vector | Status | Where |
|---|---|---|---|
| 1 | XSS | 🟢 Mostly mitigated | HttpOnly cookies (Part 2) + strict CSP headers (Step 11) |
| 2 | SQL Injection | 🟢 Mitigated by default | Active Record + strong params throughout controllers |
| 3 | CSRF | 🟡 Partially mitigated | SameSite cookies + pinned CORS origins. Explicit CSRF tokens still pending. Part 4. |
| 4 | Brute Force | 🟢 Mitigated | bcrypt + Rack-Attack IP and email throttles (Step 10) |
| 5 | User Enumeration | 🟢 Mitigated | Generic "Invalid credentials" message (Step 5) |
| 6 | IDOR | 🔴 Not yet | Will be addressed with Pundit policies + Sqids. Part 5. |
| 7 | Mass Assignment | 🟢 Mitigated | Strong params in every controller (Step 6) |
| 8 | Excessive Data Exposure | 🟡 Partially mitigated | Hand-built response hashes. Formal serializers in Part 4. |
| 9 | MITM | 🟢 Mostly mitigated | HSTS (Step 11) + secure cookies. force_ssl in Part 4. |
| 10 | Token Theft | 🟢 Mitigated | HttpOnly + encrypted cookies + short tokens + rotation + revocation on logout |
| 11 | Verbose Error Messages | 🟡 Partially mitigated | Generic 403/404 responses (Step 4). Production rescue handler in Part 4. |
Legend: 🟢 Covered, 🟡 Partial, 🔴 Pending
Coming up in Part 4
We finish the security checklist. CSRF tokens for the endpoints that need them, force_ssl and the production error handler, and we'll introduce a serializer library so we stop hand-rolling response hashes in every action. After that we'll be ready to start adding actual resources (and the IDOR mitigation that comes with them) in Part 5.
If this helped, follow along so you catch the next one. And if anything broke or didn't make sense, drop a comment, I read all of them.


Top comments (0)