When building a Rails API app, you typically need token-based authentication instead of cookie-based sessions (which are more common in full-stack Rails apps). Here are the most common authentication mechanisms you can use in a Rails API-only application:
🔐 1. Token-Based Authentication
Most Common & Recommended for APIs
a. JWT (JSON Web Tokens)
- Gems:
jwt,knock,devise-jwt - How it works: After login, the server issues a JWT token which the client must include in the
Authorizationheader (Bearer <token>) in subsequent requests. - Pros:
- Stateless, scalable.
- Widely supported across mobile and frontend frameworks.
- Cons:
- Tokens can’t be invalidated easily without extra measures (e.g., a blacklist).
b. Token-based Auth with Devise + TokenAuthenticatable
- Gems:
devise_token_auth - Uses Devise under the hood.
- Stores tokens on the server (in DB), enabling logout and token revocation.
- Compatible with React Native and SPAs.
🔐 2. OAuth 2.0 / OmniAuth (for Third-party Logins)
- Gems:
omniauth,doorkeeper - Use when you want users to log in via:
- GitHub
- Doorkeeper is often used to implement OAuth 2 provider (if you’re exposing your API to other apps).
- Best when integrating external identity providers.
🔐 3. API Key Authentication
- Useful for machine-to-machine communication or when exposing APIs to third-party developers.
- Each user/client is assigned a unique API key.
- Example:
Authorization: Token token=abc123 - You store the API key in the DB and verify it on each request.
- Lightweight and easy to implement.
🔐 4. HTTP Basic Authentication
- Simple and built-in with Rails (
authenticate_or_request_with_http_basic). - Not suitable for production unless combined with HTTPS and only used for internal/testing tools.
👉🏻 Choosing the Right Auth Mechanism
| Use Case | Recommended Method |
|---|---|
| Mobile app or frontend SPA | JWT (devise-jwt / knock) |
| Internal API between services | API key |
| Want email/password with token auth | devise_token_auth |
| External login via Google/GitHub | omniauth + doorkeeper |
| OAuth2 provider for third-party devs | doorkeeper |
| Quick-and-dirty internal auth | HTTP Basic Auth |
🔄 How JWT Authentication Works — Step by Step
1. User Logs In
- The client (e.g., React app, mobile app) sends a
POST /loginrequest with email/password. - Your Rails API validates the credentials.
- If valid, it generates a JWT token and sends it back to the client.
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
2. Client Stores the Token
- The client stores the token in localStorage, sessionStorage, or memory (for SPAs), or a secure storage for mobile apps.
3. Client Sends Token on Requests
- For any subsequent request to protected resources, the client includes the JWT in the
Authorizationheader:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
4. Server Verifies the Token
- Rails extracts the token, decodes it using a secret key, and verifies:
- The signature is valid.
- The token is not expired.
- The user ID (or sub claim) is valid.
If everything checks out, the request is allowed to proceed.
5. Token Expiration
- Tokens usually include an exp (expiration) claim, e.g., 15 minutes, 1 hour, etc.
- After expiration, the client must log in again or use a refresh token flow if supported.
🔒 Security: Is JWT Secure?
JWT can be secure, if used correctly. Here’s a breakdown:
✅ Security Benefits
| Feature | Why It Helps |
|---|---|
| Stateless | No session storage needed; scales easily |
| Signed | The token is signed (HMAC or RSA), so it can’t be tampered with |
| Compact | Sent in headers; easy to pass around |
| Exp claim | Tokens expire automatically after a period |
⚠️ Security Considerations
| Issue | Description | Mitigation |
|---|---|---|
| Token theft | If an attacker steals the token, they can impersonate the user. | Always use HTTPS. Avoid storing tokens in localStorage if possible. |
| No server-side revocation | Tokens can’t be invalidated until they expire. | Use short-lived access tokens + refresh tokens or token blacklist (DB). |
| Long token lifespan | Longer expiry means higher risk if leaked. | Keep exp short (e.g., 15–30 min). Use refresh tokens if needed. |
| Poor secret handling | If your secret key leaks, anyone can forge tokens. | Store your JWT_SECRET in environment variables, never in code. |
| JWT stored in localStorage | Susceptible to XSS attacks in web apps. | Use HttpOnly cookies when possible, or protect against XSS. |
| Algorithm confusion | Attacker could force a weak algorithm. | Always validate the algorithm (alg) on decoding. Use only HMAC or RSA. |
🧪 Example Token (Decoded)
A typical JWT has three parts:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyX2lkIjoxLCJleHAiOjE3MDAwMDAwMDB9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Breakdown:
- Header (Base64-encoded JSON)
{
"alg": "HS256",
"typ": "JWT"
}
- Payload
{
"user_id": 1,
"exp": 1700000000
}
- Signature
- HMAC-SHA256 hash of header + payload + secret key.
🛡 Best Practices for JWT in Rails API
- Use
devise-jwtorknockto handle encoding/decoding securely. - Set short token lifetimes (
expclaim). - Use HTTPS only.
- Consider implementing refresh tokens for session continuation.
- Avoid token storage in localStorage unless you trust your frontend.
- Rotate secrets periodically (invalidate tokens when secrets change).
Now Let’s create a sample Rails API application and test what we learned.
🧱 Sample Rails API web app: Prerequisites
- A Rails 8 app with
--apimode enabled:rails new my_api_app --api - A
Usermodel withemailandpassword_digest. - We’ll use
bcryptfor password hashing.
✅ Step 1: Add Required Gems
In your Gemfile:
gem 'jwt'
gem 'bcrypt'
Then run:
bundle install
✅ Step 2: Generate the User Model
rails g model User email:string password_digest:string
rails db:migrate
In app/models/user.rb:
class User < ApplicationRecord
has_secure_password
end
Now you can create users with secure passwords.
✅ Step 3: Create JWT Helper Module
Create a service object or helper to encode/decode tokens.
app/lib/json_web_token.rb (create the lib folder if needed):
# app/lib/json_web_token.rb
class JsonWebToken
SECRET_KEY = Rails.application.credentials.secret_key_base
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET_KEY)
end
def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY)[0]
HashWithIndifferentAccess.new(decoded)
rescue JWT::DecodeError => e
nil
end
end
✅ Step 4: Create the Authentication Controller
rails g controller auth
app/controllers/auth_controller.rb:
class AuthController < ApplicationController
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JsonWebToken.encode(user_id: user.id)
render json: { token: token }, status: :ok
else
render json: { error: 'Invalid credentials' }, status: :unauthorized
end
end
end
✅ Step 5: Protect Other Endpoints with Authentication
Make a reusable authenticate_request method.
app/controllers/application_controller.rb:
class ApplicationController < ActionController::API
before_action :authenticate_request
attr_reader :current_user
private
def authenticate_request
header = request.headers['Authorization']
token = header.split(' ').last if header.present?
if token
decoded = JsonWebToken.decode(token)
@current_user = User.find_by(id: decoded[:user_id]) if decoded
end
render json: { error: 'Unauthorized' }, status: :unauthorized unless @current_user
end
end
Now all your controllers inherit this behaviour unless you skip_before_action.
✅ Step 6: Add Routes
config/routes.rb:
Rails.application.routes.draw do
post '/login', to: 'auth#login'
get '/profile', to: 'users#profile' # Example protected route
end
✅ Step 7: Example Protected Controller
rails g controller users
app/controllers/users_controller.rb:
class UsersController < ApplicationController
def profile
render json: { id: current_user.id, email: current_user.email }
end
end
🧪 Test It Out (Example)
Step 1: Create a User (via Rails Console)
User.create!(email: "test@example.com", password: "password123")
Step 2: Login via POST /login
POST /login
Content-Type: application/json
{
"email": "test@example.com",
"password": "password123"
}
Response:
{ "token": "eyJhbGciOi..." }
Step 3: Use Token in Authenticated Request
GET /profile
Authorization: Bearer eyJhbGciOi...
🔒 Extras You Might Add Later
- Token expiration errors
- Refresh tokens
- Token revocation (e.g., a blacklist table)
- Roles/permissions inside the token (e.g., admin claims)
Let’s now write RSpec tests for the JWT-based authentication flow we just set up in your Rails API app.
Assumptions
- You already have:
- A
Usermodel withemailandpassword_digest - An
AuthControllerwithlogin - A
UsersControllerwith a protectedprofileaction - JWT auth logic in
JsonWebToken
- A
🔧 Step 1: Add RSpec & Factory Bot
In your Gemfile (if not already added):
group :development, :test do
gem 'rspec-rails'
gem 'factory_bot_rails'
end
group :test do
gem 'faker'
end
Then install:
bundle install
rails generate rspec:install
🏭 Step 2: Setup Factory for User
spec/factories/users.rb:
FactoryBot.define do
factory :user do
email { Faker::Internet.email }
password { 'password123' }
password_confirmation { 'password123' }
end
end
🧪 Step 3: Auth Request Specs
spec/requests/auth_spec.rb:
require 'rails_helper'
RSpec.describe 'Authentication', type: :request do
let!(:user) { create(:user, password: 'password123') }
describe 'POST /login' do
context 'with valid credentials' do
it 'returns a JWT token' do
post '/login', params: { email: user.email, password: 'password123' }
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)).to include('token')
end
end
context 'with invalid credentials' do
it 'returns unauthorized' do
post '/login', params: { email: user.email, password: 'wrong' }
expect(response).to have_http_status(:unauthorized)
expect(JSON.parse(response.body)).to include('error')
end
end
end
end
🔒 Step 4: Profile (Protected) Request Specs
spec/requests/users_spec.rb:
require 'rails_helper'
RSpec.describe 'Users', type: :request do
let!(:user) { create(:user) }
let(:token) { JsonWebToken.encode(user_id: user.id) }
describe 'GET /profile' do
context 'with valid token' do
it 'returns user profile' do
get '/profile', headers: { 'Authorization' => "Bearer #{token}" }
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['email']).to eq(user.email)
end
end
context 'without token' do
it 'returns unauthorized' do
get '/profile'
expect(response).to have_http_status(:unauthorized)
end
end
context 'with invalid token' do
it 'returns unauthorized' do
get '/profile', headers: { 'Authorization' => 'Bearer invalid.token' }
expect(response).to have_http_status(:unauthorized)
end
end
end
end
📦 Final Tips
- Run tests with:
bundle exec rspec - You can stub
JsonWebToken.decodein unit tests if needed to isolate auth logic.