Hey folks 👋
Welcome back. In Part 1 we walked through the 11 attack vectors that shape every decision in this series. If you skipped it, please go read it first, because everything we do from now on is a direct response to one of those threats. Without that context, the code below is just another tutorial.
In this part we are going to start writing the API. By the end you will have a Rails 8 project with user registration, login, and token-based authentication using OAuth2 + JWT, with tokens stored safely in HttpOnly cookies instead of localStorage.
I want to be honest about something. When I first built this, I tried to do "everything at once". I added authentication, authorization, rate limiting, and serializers in the same commit, and I got lost. So in this series we are going slow on purpose. Part 2 is only about laying the foundation correctly. We will not finish every mitigation today, and that's fine.
To help us stay oriented, I'll keep a small progress tracker at the end of each post.
What we are building in Part 2
A small Rails 8 API with:
- A
Usermodel with hashed passwords (no plain text, ever) - OAuth2 password grant flow so the client can exchange email + password for a token
- JWT access tokens that the server can verify without hitting the database
- Refresh tokens with short-lived access tokens
- Tokens delivered through encrypted
HttpOnlycookies, not JSON bodies the frontend has to store manually
If you've never touched Devise or Doorkeeper before, don't worry. I'll explain why we use each piece, not just how.
Prerequisites
- Ruby on Rails 8
- PostgreSQL 14+
- Postman, Insomnia, or
curlfor testing
Step 1. Create the project in API mode
Rails has a built-in flag to skip all the browser-only middleware (cookies are gone by default, ERB views are gone, asset pipeline is gone). That's what we want for an API.
rails new secure_api_auth -T -d postgresql --api
Breaking that down:
-
-Tskips the default test suite (we'll set up testing later in the series) -
-d postgresqluses Postgres instead of SQLite -
--apistrips out browser-oriented middleware
Then:
cd secure_api_auth
Tip from experience: commit right here as "Initial commit" before you change anything. When something breaks two hours from now, having a clean baseline to
git diffagainst will save you.
Step 2. Add the gems we need
Open your Gemfile and add:
# Database
gem 'pg'
# Password hashing
gem 'bcrypt', '~> 3.1.7'
# Authentication & Authorization
gem 'devise'
gem 'doorkeeper'
gem 'doorkeeper-jwt'
Then:
bundle install
A quick mental model before we go further, because these three gems confused me for a long time:
- Devise owns the user identity. It knows how to store a user, hash a password, and verify "is this the right password for this email?"
- Doorkeeper owns the access decision. After Devise confirms who you are, Doorkeeper issues the token that proves you are allowed to call the API.
- doorkeeper-jwt changes the format of that token from a random string to a JWT, so the server can verify it without a database lookup.
In other words: Devise checks the ID at the door, Doorkeeper hands you the wristband, and JWT is what the wristband is made of.
Step 3. Install Devise
bin/rails generate devise:install
Devise will print a few setup instructions. Since we're API-only, we only care about one of them: setting the default URL options for development.
Open config/environments/development.rb and add:
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
We won't be sending real emails in this tutorial, but Devise complains if this isn't set.
Generate the User model
bin/rails generate devise User
bin/rails db:migrate
This creates a users table with an encrypted_password column (and a few others for tracking sign-in counts and lockouts).
🛡️ Mitigation in action: Token Theft and Password Breaches (Part 1, vectors 4 and 10)
Devise hashes passwords with bcrypt, which is intentionally slow. Even if an attacker dumps your database, they can't reverse the hashes. Each password also gets a unique salt, so two users who pick the same password end up with completely different hashes. This is why we never, ever store passwords in plain text.
Switch Devise to API mode
By default Devise wants to redirect users to HTML pages after login. We need to turn that off.
Open config/initializers/devise.rb and add (or modify) these two lines:
Devise.setup do |config|
# ... keep everything else as generated ...
# Don't use session storage for API authentication
config.skip_session_storage = [:http_auth, :params_auth]
# No HTML redirects, we return JSON
config.navigational_formats = []
end
Step 4. Install Doorkeeper
bin/rails generate doorkeeper:install
bin/rails generate doorkeeper:migration
Before we run that migration, we need to edit it. Doorkeeper is built for the full OAuth2 flow (the one where you click "Log in with GitHub" and get redirected). We don't need that. We need the simpler password grant flow, where the client sends email + password directly and gets a token back. That requires loosening two null: false constraints.
Open the generated migration file (something like db/migrate/XXXXX_create_doorkeeper_tables.rb):
# In the oauth_applications table: allow null redirect_uri
t.text :redirect_uri # remove the `null: false`
# In the oauth_access_tokens table: allow null application reference
t.references :application # remove the `null: false`
# At the bottom of the file, link tokens back to users
add_foreign_key :oauth_access_grants, :users, column: :resource_owner_id
add_foreign_key :oauth_access_tokens, :users, column: :resource_owner_id
Then migrate:
bin/rails db:migrate
A word on the password grant flow. The OAuth2 spec actually discourages this flow for third-party clients, because it requires the client to handle the user's raw password. But for first-party clients (your own mobile app, your own SPA), it's perfectly reasonable, and it's what most "log in with email and password" APIs do under the hood. The key word is first-party: you control both ends.
Step 5. Re-enable cookies in API mode
This is the part that surprised me the first time. When you pass --api to rails new, Rails removes the cookie middleware. That makes sense, an API doesn't usually need cookies. But we do want them, because we want to store the access token in an HttpOnly cookie instead of letting JavaScript handle it.
Open config/application.rb:
module SecureApiAuth
class Application < Rails::Application
config.load_defaults 8.0
config.api_only = true
# Re-add cookie middleware so we can use HttpOnly cookie auth
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore,
key: '_secure_api_session',
same_site: :lax,
secure: Rails.env.production?
end
end
🛡️ Mitigation in action: XSS and Token Theft (Part 1, vectors 1 and 10)
This is the single most important decision in this whole post. If you store a JWT in
localStorage, any piece of JavaScript that runs on your page can read it. One XSS bug, one compromised npm dependency, one browser extension, and the token is gone.
HttpOnlycookies are invisible to JavaScript. The browser sends them automatically with every request, butdocument.cookiewill not show them.Secureensures the cookie is only sent over HTTPS.SameSite=Laxis our first line of defense against CSRF (which we'll fully address in a later part).
Step 6. Teach Doorkeeper to read tokens from the cookie
Out of the box, Doorkeeper looks for tokens in the Authorization: Bearer ... header. We need to add a new place for it to look: our encrypted cookie.
Create lib/doorkeeper/from_cookie.rb:
module Doorkeeper
module FromCookie
module_function
def call(request)
# Read the encrypted access token from the HttpOnly cookie
request.cookie_jar.encrypted[:access_token]
end
end
end
Why
encryptedand not justsigned?Rails offers two protected cookie jars:
signed(tamper-proof but readable) andencrypted(tamper-proof AND unreadable). If someone opens DevTools and copies the raw cookie value, withsignedthey'd see the JWT in plain text. Withencryptedthey see AES-256 garbage. The JWT is already protected by its own signature, but defense in depth is cheap here, so we useencrypted.
Step 7. Configure Doorkeeper
Open config/initializers/doorkeeper.rb and replace the generated content with:
# frozen_string_literal: true
require_relative '../../lib/doorkeeper/from_cookie'
Doorkeeper.configure do
orm :active_record
api_only
# Resource Owner Password Credentials grant.
# Lets users log in with email + password directly.
grant_flows %w[password]
allow_blank_redirect_uri true
# How to find the user when they log in.
resource_owner_from_credentials do |_routes|
user = User.find_for_database_authentication(email: params[:email])
user if user&.valid_password?(params[:password])
end
skip_client_authentication_for_password_grant true
# Short-lived access tokens.
access_token_expires_in 15.minutes
# Enable refresh tokens.
use_refresh_token
# Use JWT format for access tokens.
access_token_generator 'Doorkeeper::JWT'
# Scopes. We'll use these later for authorization.
default_scopes :read
optional_scopes :write, :admin
base_controller 'ApplicationController'
# Token lookup order: 1) our cookie, 2) Bearer header, 3) query param.
access_token_methods Doorkeeper::FromCookie,
:from_bearer_authorization,
:from_access_token_param
end
A few of these settings deserve a closer look:
access_token_expires_in 15.minutes is intentional. If a token leaks, the attacker only has 15 minutes before it stops working. The refresh token (which lives longer) covers the user experience side, so they don't have to log in every 15 minutes.
use_refresh_token enables the rotation pattern: when an access token expires, the client uses a refresh token to get a new one without prompting the user. We'll wire up rotation more carefully in a later part.
access_token_methods order matters here. Doorkeeper checks the cookie first, then the Authorization header, then a query parameter. In production I'd actually remove from_access_token_param because tokens in URLs end up in server logs and browser history (Part 1, vector 10). For now I'm leaving it in so you can test easily with curl, but treat it as a TODO.
🛡️ Mitigation in action: Token Theft (Part 1, vector 10)
Short access token lifetime plus refresh token rotation is the standard pattern for limiting blast radius when a token leaks. We'll harden this further by adding revocation when we build the
/logoutendpoint.
Step 8. Configure the JWT payload
Create config/initializers/doorkeeper_jwt.rb:
Doorkeeper::JWT.configure do
# Sign tokens with HS256 using the app's secret key.
secret_key Rails.application.credentials.secret_key_base
signing_method :hs256
token_payload do |opts|
user = User.find(opts[:resource_owner_id])
{
iat: Time.current.to_i, # Issued at
exp: (Time.current + opts[:expires_in]).to_i, # Expires at
jti: SecureRandom.uuid, # Unique token ID
sub: user.id, # Subject (user ID)
email: user.email,
scopes: opts[:scopes]
}
end
secret_key_path nil
use_application_secret false
end
If you've never seen a JWT before, here's the short version. A JWT is three base64-encoded parts joined by dots:
header.payload.signature
The header says how the token is signed. The payload is the JSON we just defined above (user ID, email, expiry, etc). The signature is a hash of header.payload combined with our secret key. If an attacker changes anything in the payload, the signature won't match anymore and the token is rejected.
The jti (JWT ID) is worth flagging. It's a unique ID per token, which we'll use later when we implement token revocation. You "blacklist" the jti instead of trying to delete the JWT itself (you can't, the client has it).
Where we are right now
We have a Rails 8 API with a User model, password hashing, OAuth2 password grant, JWT access tokens, refresh tokens, and HttpOnly cookie storage. That's a solid foundation.
But we have not written the controllers yet (register, login, logout, refresh, "who am I"). That's coming in Part 3, along with the first set of mitigations that depend on having endpoints to protect.
Progress tracker: security vectors from Part 1
I'll keep this table updated in every part so you can see exactly what's covered and what's still pending.
| # | Attack vector | Status | Where |
|---|---|---|---|
| 1 | XSS | 🟡 Partially mitigated |
HttpOnly cookie storage (Step 5). Full content sanitization is a frontend concern. |
| 2 | SQL Injection | 🟢 Mitigated by default | Active Record's find_by, where, etc. We'll still review controllers in Part 3. |
| 3 | CSRF | 🔴 Not yet |
SameSite=Lax is set, but we need explicit CSRF tokens for cookie auth. Part 3. |
| 4 | Brute Force | 🟡 Partially mitigated | bcrypt slows password cracking. Rate limiting with Rack::Attack comes in Part 4. |
| 5 | User Enumeration | 🔴 Not yet | We need to design login/reset responses to be uniform. Part 3. |
| 6 | IDOR | 🔴 Not yet | Will be addressed when we add resources + Pundit/Sqids. Part 5. |
| 7 | Mass Assignment | 🔴 Not yet | Strong params, controller by controller. Part 3. |
| 8 | Excessive Data Exposure | 🔴 Not yet | Serializers (JSON:API or alba). Part 3. |
| 9 | MITM | 🟡 Partially mitigated |
secure: true on cookies in production. force_ssl and HSTS come in Part 4. |
| 10 | Token Theft | 🟢 Mostly mitigated |
HttpOnly + encrypted cookies + short-lived tokens + refresh tokens. Revocation in Part 3. |
| 11 | Verbose Error Messages | 🔴 Not yet | Production error handling. Part 4. |
Legend: 🟢 Covered, 🟡 Partial, 🔴 Pending
Coming up in Part 3
We'll build the actual 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.
If this helped you, follow along so you don't miss it. And if anything is unclear, drop a comment, I read all of them.
Top comments (0)