🪟 Windows TippsThe Gemini desktop app is now available for Windows(11.09.2026 um 17:06 Uhr)
🪟 Windows TippsHeader and Footer not showing in Excel(14.09.2026 um 22:43 Uhr)
🕵️ SicherheitslückenBurn Out, Or Fade Away(14.09.2026 um 14:25 Uhr)
🪟 Windows TippsKB5129194 Windows 11 26H1 Out of Band Update - Deskmodder.de(14.09.2026 um 19:25 Uhr)
🪟 Windows TippsThe Gemini desktop app is now available for Windows(11.09.2026 um 17:06 Uhr)
🪟 Windows TippsHeader and Footer not showing in Excel(14.09.2026 um 22:43 Uhr)
🕵️ SicherheitslückenBurn Out, Or Fade Away(14.09.2026 um 14:25 Uhr)
🪟 Windows TippsKB5129194 Windows 11 26H1 Out of Band Update - Deskmodder.de(14.09.2026 um 19:25 Uhr)

🔧 Programmierung 🕛 vor 4 Monaten 12 Min Lesezeit
0

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

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

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.




CODE
rails new secure_api_auth -T -d postgresql --api






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:




CODE
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 diff against will save you.







Step 2. Add the gems we need



Open your Gemfile and add:




CODE
# Database
gem 'pg'

# Password hashing
gem 'bcrypt', '~> 3.1.7'

# Authentication & Authorization
gem 'devise'
gem 'doorkeeper'
gem 'doorkeeper-jwt'






Then:




CODE
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






CODE
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:




CODE
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






CODE
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:




CODE
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






CODE
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):




CODE
# 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:




CODE
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:




CODE
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.



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:




CODE
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 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:




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







Step 8. Configure the JWT payload



Create config/initializers/doorkeeper_jwt.rb:




CODE
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:




CODE
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.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
The Gemini desktop app is now available for Windows
1 Quelle
Header and Footer not showing in Excel
1 Quelle
Burn Out, Or Fade Away
Ähnliche Beiträge
🔍 Verwandte News

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

Thematisch verwandte Begriffe: Build, Secure, with, Rails · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...