Keycloak Authentication with Ruby on Rails
Last updated: June 2026
Rails connects to Keycloak via OmniAuth’s OpenID Connect strategy — the omniauth_openid_connect gem — pointed at the realm discovery URL. The strategy handles the authorization-code flow with PKCE and delivers a normalized auth hash to your callback action, so you never touch raw OIDC protocol details in application code.
Developers reaching for Keycloak authentication in Rails often hit the same friction points: which gem handles discovery correctly, how to wire routes without breaking Rails’ CSRF protection, and how to validate JWTs from an API client that skips the browser flow entirely. This tutorial covers all three. By the end you’ll have a working SSO login, session management, RP-initiated logout, and bearer-token validation using Keycloak 26.x-compatible paths.
Prerequisites
Before writing any code, make sure you have:
- Ruby 3.2+ and Rails 7.1+
- A running Keycloak 26.x instance — you can start one locally with Docker or use a managed provider like Skycloak
- Basic familiarity with Rails conventions (routes, controllers, sessions)
To spin up a local Keycloak server for development:
docker run -p 8080:8080
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin
quay.io/keycloak/keycloak:26.0 start-dev
The admin console is at http://localhost:8080 with credentials admin/admin.
How to Configure a Keycloak Client for Rails
The first step happens entirely in Keycloak’s admin console, before touching any Ruby code.
Create a realm
Log into http://localhost:8080/admin, click the realm dropdown in the upper-left, and select Create realm. Name it something descriptive — my-rails-app — and click Create. Never use the master realm for application workloads; it’s reserved for Keycloak administration.
Create a confidential client
- Navigate to Clients and click Create client
- Set Client type to
OpenID Connect - Set Client ID to
rails-app(or whatever identifier you prefer) - Click Next, then enable Standard flow (authorization code flow) and disable all others
- Click Next, set Access type to Confidential (the default in Keycloak 26.x is called Client authentication: On)
- Add your callback URL to Valid redirect URIs:
http://localhost:3000/auth/keycloak/callback - Add
http://localhost:3000to Web origins for CORS (see the guide on configuring CORS with your Keycloak OIDC client for production settings) - Click Save
After saving, open the Credentials tab and copy the Client secret — you’ll need it in the Rails initializer.
Note the discovery URL
Keycloak publishes an OpenID Connect discovery document at:
https://{host}/realms/{realm}/.well-known/openid-configuration
For local development that’s http://localhost:8080/realms/my-rails-app/.well-known/openid-configuration. The omniauth_openid_connect gem fetches this document at startup and uses it to locate all endpoints automatically, so you don’t hardcode token or authorization URLs.
Setting Up the Gemfile
Add three gems to your Gemfile:
# Gemfile
# OpenID Connect strategy for OmniAuth
# Docs: https://github.com/omniauth/omniauth_openid_connect
gem "omniauth_openid_connect"
# Prevents CSRF attacks on the OmniAuth callback (required for Rails 5.2+)
gem "omniauth-rails_csrf_protection"
# JWT decoding for API bearer-token validation
gem "jwt"
The omniauth-rails_csrf_protection gem is not optional. Without it, an attacker can craft a forged callback request that Rails would accept. Run bundle install after adding these entries.
Understanding the OpenID Connect specification helps you reason about what happens at each step of the flow — the strategy automates it, but knowing the spec makes debugging much faster.
Configuring OmniAuth with the Keycloak Issuer
Create config/initializers/omniauth.rb:
# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider :openid_connect, {
name: :keycloak,
scope: [:openid, :email, :profile],
response_type: :code,
discovery: true,
issuer: ENV.fetch("KEYCLOAK_ISSUER"), # https://{host}/realms/{realm}
client_auth_method: :client_secret_post,
client_options: {
identifier: ENV.fetch("KEYCLOAK_CLIENT_ID"),
secret: ENV.fetch("KEYCLOAK_CLIENT_SECRET"),
redirect_uri: ENV.fetch("KEYCLOAK_REDIRECT_URI"),
},
pkce: true,
}
end
# OmniAuth 2.x requires POST for the initiation endpoint.
# omniauth-rails_csrf_protection enforces this automatically.
OmniAuth.config.allowed_request_methods = %i[post]
OmniAuth.config.silence_get_warning = false
Set these environment variables in your .env file (or credentials store):
KEYCLOAK_ISSUER=http://localhost:8080/realms/my-rails-app
KEYCLOAK_CLIENT_ID=rails-app
KEYCLOAK_CLIENT_SECRET=<paste-from-keycloak-credentials-tab>
KEYCLOAK_REDIRECT_URI=http://localhost:3000/auth/keycloak/callback
discovery: true tells the gem to GET the .well-known/openid-configuration document and populate all endpoint URLs automatically — no need to hardcode the token or authorization URLs. Setting pkce: true enables PKCE (Proof Key for Code Exchange), which Keycloak 26.x supports for confidential clients and which defends against authorization-code interception.
For a deeper look at how the code flow works end-to-end, the OpenID Connect guide for developers is a useful companion.
Wiring Routes and the Sessions Controller
Add two routes to config/routes.rb:
# config/routes.rb
Rails.application.routes.draw do
# OmniAuth initiates the flow via POST to /auth/keycloak
# (enforced by omniauth-rails_csrf_protection)
post "/auth/keycloak", to: "sessions#new", as: :auth_keycloak
# Keycloak redirects back here after login
get "/auth/keycloak/callback", to: "sessions#create", as: :auth_keycloak_callback
# Also handle provider-side failures
get "/auth/failure", to: "sessions#failure", as: :auth_failure
delete "/logout", to: "sessions#destroy", as: :logout
root "home#index"
end
Now create app/controllers/sessions_controller.rb:
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
skip_before_action :verify_authenticity_token, only: [:create]
# GET /auth/keycloak/callback
# OmniAuth populates request.env["omniauth.auth"] with the normalized auth hash.
def create
auth = request.env["omniauth.auth"]
# Pull the fields you need from the normalized hash
session[:user_id] = auth.uid # Keycloak sub claim
session[:user_email] = auth.info.email
session[:user_name] = auth.info.name
session[:access_token] = auth.credentials.token
session[:refresh_token] = auth.credentials.refresh_token
session[:id_token] = auth.extra.raw_info["id_token"] || auth.credentials.id_token
redirect_to root_path, notice: "Signed in as #{session[:user_name]}"
end
# DELETE /logout -- triggers RP-initiated logout
def destroy
id_token = session[:id_token]
session.clear
# Build the end_session_endpoint URL
issuer = ENV.fetch("KEYCLOAK_ISSUER")
end_session_uri = URI("#{issuer}/protocol/openid-connect/logout")
post_logout_uri = ENV.fetch("KEYCLOAK_POST_LOGOUT_URI", root_url)
end_session_uri.query = URI.encode_www_form(
id_token_hint: id_token,
post_logout_redirect_uri: post_logout_uri,
client_id: ENV.fetch("KEYCLOAK_CLIENT_ID"),
)
redirect_to end_session_uri.to_s, allow_other_host: true
end
# GET /auth/failure
def failure
flash[:alert] = "Authentication failed: #{params[:message]}"
redirect_to root_path
end
# sessions#new is handled entirely by OmniAuth middleware;
# the route exists only for named-route helpers.
def new; end
end
A few things worth noting here. skip_before_action :verify_authenticity_token, only: [:create] is needed because the callback is a GET from Keycloak — it doesn’t carry a Rails CSRF token. The omniauth-rails_csrf_protection gem handles the initiation CSRF separately (via the state parameter); Rails’ built-in CSRF token is irrelevant for the callback itself.
The destroy action implements RP-initiated logout per OpenID Connect Session Management. Passing id_token_hint tells Keycloak which session to terminate server-side. Without it, Keycloak may show an intermediate confirmation page. Add KEYCLOAK_POST_LOGOUT_URI to your environment pointing at the URL Keycloak should redirect to after logout (must be registered in the client’s Valid post-logout redirect URIs list).
Protecting Controllers with a Before Action
Add a current_user helper and an authentication guard to ApplicationController:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
helper_method :current_user, :user_signed_in?
private
def current_user
return nil unless session[:user_id]
# Return a lightweight struct -- swap in an ActiveRecord lookup if you persist users
@current_user ||= OpenStruct.new(
id: session[:user_id],
email: session[:user_email],
name: session[:user_name],
)
end
def user_signed_in?
current_user.present?
end
def authenticate_user!
unless user_signed_in?
redirect_to auth_keycloak_path, alert: "Please sign in to continue."
end
end
end
Then protect any controller by calling the before action:
# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
before_action :authenticate_user!
def index
# session[:access_token] is available here if you need to call downstream APIs
end
end
This is the session-based protection path. The API path — where clients send a bearer token rather than holding a browser session — requires a different approach, covered in the next section.
Validating Bearer JWTs in a Rails API
API clients (mobile apps, CLIs, other services) don’t go through the browser flow. They obtain an access token from Keycloak directly and attach it to every request as a Bearer token. You need to validate that token server-side without trusting the client.
Keycloak publishes its public signing keys at:
https://{host}/realms/{realm}/protocol/openid-connect/certs
The jwt gem can fetch those keys and verify the token signature. Create a concern:
# app/controllers/concerns/jwt_authenticatable.rb
require "net/http"
require "json"
module JwtAuthenticatable
extend ActiveSupport::Concern
included do
before_action :authenticate_jwt!
end
private
JWKS_CACHE_TTL = 3600 # seconds
def jwks
# Cache the JWKS response for one hour to avoid hammering the endpoint.
@jwks ||= Rails.cache.fetch("keycloak_jwks", expires_in: JWKS_CACHE_TTL) do
uri = URI("#{ENV.fetch("KEYCLOAK_ISSUER")}/protocol/openid-connect/certs")
response = Net::HTTP.get_response(uri)
raise "JWKS fetch failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
end
end
def authenticate_jwt!
token = bearer_token
return render_unauthorized("Missing bearer token") unless token
payload, = JWT.decode(
token,
nil, # key resolved via JWKS
true, # verify signature
{
algorithms: ["RS256"],
jwks: jwks,
iss: ENV.fetch("KEYCLOAK_ISSUER"),
verify_iss: true,
aud: ENV.fetch("KEYCLOAK_CLIENT_ID"),
verify_aud: true,
}
)
@jwt_payload = payload
rescue JWT::DecodeError => e
render_unauthorized("Invalid token: #{e.message}")
end
def bearer_token
header = request.headers["Authorization"]
header&.delete_prefix("Bearer ")
end
def render_unauthorized(message)
render json: { error: message }, status: :unauthorized
end
def current_user_id
@jwt_payload&.fetch("sub")
end
end
Include it in any API controller:
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < ActionController::API
include JwtAuthenticatable
end
end
end
The JWT.decode call verifies the signature (RS256 by default in Keycloak), the iss claim (must match your realm URL exactly), the aud claim (must include your client ID), and the token’s expiry. Passing jwks: as a hash hands the key-resolution responsibility to the gem, which selects the right key by the kid header in the token.
For a thorough explanation of expiry, refresh, and revocation behavior, see the post on JWT token lifecycle management. For the broader API-side validation workflow, the Keycloak token validation for APIs guide goes deeper.
A Note on CSRF Protection
CSRF in an OmniAuth context has two distinct layers that are easy to conflate.
Layer 1 — OmniAuth state parameter. The omniauth_openid_connect strategy generates a random state value, stores it in the session, and includes it in the authorization URL. When Keycloak redirects back, the strategy compares the state in the callback with what it stored. This defends against login CSRF (an attacker forcing you to log in as them).
Layer 2 — Rails CSRF token. The omniauth-rails_csrf_protection gem requires that the initiation request (POST /auth/keycloak) include a valid Rails authenticity token. Without this, an attacker could craft a page that POSTs to your initiation endpoint and hijacks the flow from the very start.
In your login button, always use button_to (which emits a form with method="post" and the authenticity token) rather than a plain <a> link:
<%# app/views/shared/_login.html.erb %>
<%= button_to "Sign in with Keycloak", auth_keycloak_path, method: :post %>
The SSO implementation guide for developers covers CSRF and state management in more detail across different SSO patterns.
Frequently asked questions
Does omniauth_openid_connect work with Keycloak 26?
Yes. Keycloak 26.x continues to expose standard OIDC endpoints at /realms/{realm}/protocol/openid-connect/..., and its discovery document at /.well-known/openid-configuration is fully compatible with the omniauth_openid_connect gem. Set discovery: true and the gem resolves all endpoints automatically. The only breaking change from Keycloak 25 to 26 that affects this setup is the removal of legacy endpoints that didn’t use the /realms/ path prefix — if you were previously hardcoding those, update to the /realms/{realm}/... form.
How do I store users in the database on first login?
In the SessionsController#create action, after reading auth.uid, look up or create a User record:
user = User.find_or_create_by(keycloak_id: auth.uid) do |u|
u.email = auth.info.email
u.name = auth.info.name
end
session[:user_id] = user.id
This is called “just-in-time provisioning.” Keycloak remains the source of truth for credentials; your database stores only application-specific profile data. The SSO implementation guide for developers covers JIT provisioning patterns in detail.
How do I handle token refresh in a Rails session?
Access tokens issued by Keycloak are short-lived (default: 5 minutes). For web sessions, you have two options. The simpler approach is to re-initiate the OIDC flow when the token expires — most users won’t notice because Keycloak will issue a new token silently if the SSO session is still active. The more robust approach is to exchange the refresh_token stored in session before each request. Call Keycloak’s token endpoint with grant_type=refresh_token; on success, update session[:access_token] and session[:refresh_token]. Add a before_action that checks Time.now against a stored expiry timestamp to decide when to refresh. See the post on JWT token lifecycle management for implementation patterns.
Can I use Devise alongside OmniAuth for Keycloak?
Yes, Devise ships with OmniAuth support via devise_invitable or the built-in :omniauthable module. Add :omniauthable, omniauth_providers: [:keycloak] to your User model and generate a Devise OmniAuth callbacks controller. The Keycloak initializer configuration is identical to the standalone approach shown above. The main trade-off is added complexity: Devise manages its own session and CSRF logic, so read the Devise OmniAuth documentation carefully before mixing both.
How do I validate role-based access from a Keycloak JWT?
Keycloak embeds realm and client roles in the access token under realm_access.roles and resource_access.{client-id}.roles respectively. After decoding the token in JwtAuthenticatable, read roles from the payload:
def current_user_roles
@jwt_payload&.dig("realm_access", "roles") || []
end
def require_role!(role)
unless current_user_roles.include?(role.to_s)
render json: { error: "Forbidden" }, status: :forbidden
end
end
Call require_role!(:admin) as a before_action in any controller that needs role gating. For a complete walkthrough of Keycloak’s role model, see the OpenID Connect guide for developers.
Adding SSO to a Rails app doesn’t have to mean handing user data to a third party. Keycloak gives you a complete, self-hosted identity stack — OIDC, MFA, role management, federation — and the omniauth_openid_connect gem handles the protocol complexity so your Rails code stays focused on application logic.
If managing Keycloak infrastructure isn’t where you want to spend your time, Skycloak runs Keycloak for you: automatic upgrades, high availability, and a managed control plane, so you get the full Keycloak feature set without the ops burden.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.