DEV Community

Renzo Diaz
Renzo Diaz

Posted on

Build a Secure API with Rails 8 - Part-2: Authentication Foundations

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 User model 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 HttpOnly cookies, 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 curl for 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
Enter fullscreen mode Exit fullscreen mode

Breaking that down:

  • -T skips the default test suite (we'll set up testing later in the series)
  • -d postgresql uses Postgres instead of SQLite
  • --api strips out browser-oriented middleware

Then:

cd secure_api_auth
Enter fullscreen mode Exit fullscreen mode

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 diff against 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'
Enter fullscreen mode Exit fullscreen mode

Then:

bundle install
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 4. Install Doorkeeper

bin/rails generate doorkeeper:install
bin/rails generate doorkeeper:migration
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then migrate:

bin/rails db:migrate
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🛡️ 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.

HttpOnly cookies are invisible to JavaScript. The browser sends them automatically with every request, but document.cookie will not show them. Secure ensures the cookie is only sent over HTTPS. SameSite=Lax is 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
Enter fullscreen mode Exit fullscreen mode

Why encrypted and not just signed?

Rails offers two protected cookie jars: signed (tamper-proof but readable) and encrypted (tamper-proof AND unreadable). If someone opens DevTools and copies the raw cookie value, with signed they'd see the JWT in plain text. With encrypted they see AES-256 garbage. The JWT is already protected by its own signature, but defense in depth is cheap here, so we use encrypted.

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
Enter fullscreen mode Exit fullscreen mode

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 /logout endpoint.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)