Keycloak Authentication with Ruby on Rails

Guilliano Molaire Guilliano Molaire Updated June 11, 2026 10 min read

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

  1. Navigate to Clients and click Create client
  2. Set Client type to OpenID Connect
  3. Set Client ID to rails-app (or whatever identifier you prefer)
  4. Click Next, then enable Standard flow (authorization code flow) and disable all others
  5. Click Next, set Access type to Confidential (the default in Keycloak 26.x is called Client authentication: On)
  6. Add your callback URL to Valid redirect URIs: http://localhost:3000/auth/keycloak/callback
  7. Add http://localhost:3000 to Web origins for CORS (see the guide on configuring CORS with your Keycloak OIDC client for production settings)
  8. 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.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman