Keycloak Authentication with Elixir and Phoenix
Last updated: June 2026
Phoenix connects to Keycloak via OpenID Connect using Ueberauth with an OIDC strategy — specifically ueberauth_oidcc, built on the Erlang oidcc library — pointed at the realm discovery document. The library handles the authorization code flow with PKCE, fetches and caches the JWKS automatically, and delivers verified claims to your callback controller. You don’t write the token validation logic yourself; oidcc owns that path.
If you’ve been doing this manually with raw HTTP calls to Keycloak, this approach is cleaner, safer, and far easier to maintain.
Setting Up the Keycloak Client
Before touching Elixir code, you need a client registered in your Keycloak realm. Log in to the Keycloak admin console (Keycloak 26.x), navigate to your realm, and open Clients > Create client.
Configure the client:
- Client type: OpenID Connect
- Client ID:
my-phoenix-app(or any identifier you choose) - Client authentication: ON (confidential client)
- Authentication flow: Standard flow (authorization code)
- Valid redirect URIs:
http://localhost:4000/auth/keycloak/callbackfor local dev, plus your production URI - Valid post logout redirect URIs:
http://localhost:4000/(used for logout) - Web origins:
http://localhost:4000
After saving, open the Credentials tab and copy the client secret. You’ll need the realm name, client ID, and client secret in the next step.
The realm discovery document Keycloak exposes follows a standard path:
https://{your-keycloak-host}/realms/{realm-name}/.well-known/openid-configuration
This document advertises every endpoint — authorization, token, JWKS, end_session — so oidcc needs only the issuer URL to bootstrap itself. Understanding OpenID Connect at the protocol level will help you interpret what each endpoint does.
Adding Mix Dependencies
Open your mix.exs and add the following to deps/0:
defp deps do
[
{:phoenix, "~> 1.7"},
{:ueberauth, "~> 0.10"},
# ueberauth_oidcc is the OIDC strategy built on the Erlang oidcc library
# https://hex.pm/packages/ueberauth_oidcc
{:ueberauth_oidcc, "~> 0.4"},
# oidcc is pulled in transitively but pin it for explicit version control
{:oidcc, "~> 3.2"},
# JOSE for manual JWT verification in the API plug (covered later)
{:jose, "~> 1.11"},
{:plug, "~> 1.16"},
{:plug_cowboy, "~> 2.7"}
]
end
Run mix deps.get to fetch everything. The oidcc library is an Erlang OTP application that manages provider discovery, JWKS caching, and token validation as a supervised process — it starts automatically when your application starts.
Configuring Ueberauth and OIDCC
Add Ueberauth configuration in config/config.exs:
config :ueberauth, Ueberauth,
providers: [
keycloak: {Ueberauth.Strategy.OIDCC, []}
]
config :ueberauth, Ueberauth.Strategy.OIDCC,
provider: :keycloak_oidcc_provider,
uid_field: "sub",
# Request scopes; openid is required, profile and email are common additions
scopes: "openid profile email"
# Register the OIDCC provider — this is the supervised process that fetches
# and caches the Keycloak discovery document and JWKS
config :oidcc,
provider_configuration_worker: [
issuer: System.get_env("KEYCLOAK_ISSUER", "https://auth.example.com/realms/myrealm"),
name: :keycloak_oidcc_provider
]
config :ueberauth_oidcc,
providers: %{
keycloak_oidcc_provider: %{
client_id: System.get_env("KEYCLOAK_CLIENT_ID", "my-phoenix-app"),
client_secret: System.get_env("KEYCLOAK_CLIENT_SECRET", "change-me")
}
}
Never hard-code secrets. Use environment variables or a runtime config file (config/runtime.exs) so credentials stay out of version control. The issuer value for a Keycloak 26.x realm looks like https://auth.example.com/realms/myrealm — the realm name is part of the path, not a query parameter.
The oidcc supervision tree starts the ProviderConfigurationWorker on application boot. It fetches the discovery document once, caches it, and refreshes periodically. Your app’s startup will fail if Keycloak is unreachable and no cached state exists, so plan your health check accordingly.
Router Pipeline and Auth Routes
Open lib/my_app_web/router.ex and wire up the auth routes:
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
pipeline :api_authenticated do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.BearerAuth
end
scope "/auth", MyAppWeb do
pipe_through :browser
get "/:provider", AuthController, :request
get "/:provider/callback", AuthController, :callback
delete "/logout", AuthController, :logout
end
scope "/", MyAppWeb do
pipe_through :browser
get "/", PageController, :home
get "/dashboard", DashboardController, :index
end
scope "/api", MyAppWeb do
pipe_through :api_authenticated
get "/me", ApiController, :me
end
end
The :provider path parameter will be keycloak, matching the provider name registered in the Ueberauth config above.
Auth Controller: Request and Callback Actions
Create lib/my_app_web/controllers/auth_controller.ex:
defmodule MyAppWeb.AuthController do
use MyAppWeb, :controller
plug Ueberauth
@session_key :current_user
# The request action is handled entirely by the Ueberauth plug —
# it redirects the browser to Keycloak's authorization endpoint with
# a PKCE code_challenge. You don't need to add code here.
def request(conn, _params), do: conn
def callback(%{assigns: %{ueberauth_failure: failure}} = conn, _params) do
reason = inspect(failure.errors)
conn
|> put_flash(:error, "Authentication failed: #{reason}")
|> redirect(to: ~p"/")
end
def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
user = %{
sub: auth.uid,
email: auth.info.email,
name: auth.info.name,
# Store the raw access token if you need to call downstream services
access_token: auth.credentials.token,
expires_at: auth.credentials.expires_at
}
conn
|> put_session(@session_key, user)
|> configure_session(renew: true)
|> redirect(to: ~p"/dashboard")
end
def logout(conn, _params) do
user = get_session(conn, @session_key)
# Build the end_session_endpoint URL so Keycloak also invalidates
# the SSO session. Without this, the user remains logged in to Keycloak
# even after your app clears its local session.
issuer = Application.get_env(:oidcc, :provider_configuration_worker)[:issuer]
post_logout_uri = url(~p"/")
end_session_url =
"#{issuer}/protocol/openid-connect/logout" <>
"?id_token_hint=#{user[:access_token]}" <>
"&post_logout_redirect_uri=#{URI.encode_www_form(post_logout_uri)}"
conn
|> configure_session(drop: true)
|> redirect(external: end_session_url)
end
end
The Ueberauth plug intercepts the request action and performs the redirect to Keycloak automatically. On the callback path it exchanges the authorization code for tokens, validates the ID token signature against the cached JWKS, and populates conn.assigns.ueberauth_auth with verified claims. You only handle the result.
The logout action hits Keycloak’s end_session_endpoint — the path is /realms/{realm}/protocol/openid-connect/logout in Keycloak 26.x. Passing id_token_hint lets Keycloak confirm which session to terminate without prompting the user again. If you omit it, Keycloak may show a confirmation screen.
For a deeper look at the full SSO implementation pattern, including session lifetime tuning and multi-application logout, that guide covers the details.
Storing the User in the Plug Session
The callback above calls put_session/3 and configure_session(renew: true). The renew: true option rotates the session ID after login, preventing session fixation attacks. Phoenix sessions are server-signed cookies by default (Plug.Session with :cookie store), so the session data isn’t readable by the browser, only the signed reference.
To fetch the current user in any controller or LiveView:
# In a controller
def index(conn, _params) do
user = get_session(conn, :current_user)
render(conn, :index, user: user)
end
# In a LiveView mount
def mount(_params, session, socket) do
user = Map.get(session, "current_user")
{:ok, assign(socket, :current_user, user)}
end
LiveView Session Considerations
Phoenix LiveView upgrades the HTTP connection to a WebSocket after the initial render. The session is only available during mount/3 via the session map parameter — it isn’t re-fetched on subsequent events. Store what you need in socket assigns during mount. If the access token expires mid-session, you’ll need to re-authenticate; consider storing expires_at in the session and redirecting to /auth/keycloak when it lapses.
If your LiveView needs to call Keycloak-protected upstream APIs on behalf of the user, store the access token in the session and retrieve it during mount. Don’t store it in a JS-accessible cookie.
Verifying Bearer JWTs for a Phoenix API
For API endpoints that receive bearer tokens from mobile clients or single-page applications, you can’t rely on the Ueberauth callback flow. Instead, verify the JWT directly using oidcc and JOSE.
The JWKS endpoint Keycloak exposes is:
https://{host}/realms/{realm}/protocol/openid-connect/certs
The oidcc provider worker fetches and caches this automatically, so you can call oidcc to validate tokens without making an HTTP request on every API call.
Create a verification module at lib/my_app/token_verifier.ex:
defmodule MyApp.TokenVerifier do
@moduledoc """
Verifies Keycloak-issued access tokens for API routes.
Uses oidcc to validate the signature against the cached JWKS and
checks standard claims (iss, exp, aud).
"""
@provider :keycloak_oidcc_provider
@doc """
Returns {:ok, claims} or {:error, reason}.
"""
def verify(token) when is_binary(token) do
client_id = Application.fetch_env!(:ueberauth_oidcc, :providers)
|> Map.fetch!(@provider)
|> Map.fetch!(:client_id)
case :oidcc.validate_jwt(token, @provider, %{expected_client: client_id}) do
{:ok, claims} -> {:ok, claims}
{:error, reason} -> {:error, reason}
end
end
end
oidcc validates the signature using the cached JWKS, checks exp, and verifies the iss matches the configured issuer. Reviewing JWT best practices alongside this is worthwhile — in particular, always validate iss and aud explicitly rather than trusting signature validation alone.
For the full picture of how token validation works at the API layer, including introspection versus local verification trade-offs, see our guide on Keycloak token validation for APIs.
A Plug to Enforce Auth on API Routes
Create lib/my_app_web/plugs/bearer_auth.ex:
defmodule MyAppWeb.Plugs.BearerAuth do
import Plug.Conn
alias MyApp.TokenVerifier
def init(opts), do: opts
def call(conn, _opts) do
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
{:ok, claims} <- TokenVerifier.verify(token) do
assign(conn, :current_claims, claims)
else
[] ->
conn
|> send_resp(401, Jason.encode!(%{error: "missing_token"}))
|> halt()
{:error, reason} ->
conn
|> send_resp(401, Jason.encode!(%{error: "invalid_token", detail: inspect(reason)}))
|> halt()
end
end
end
This plug is registered in the :api_authenticated pipeline in the router above. Any route using that pipeline will reject requests without a valid bearer token before they reach the controller. The assign/3 call makes the verified claims available downstream:
defmodule MyAppWeb.ApiController do
use MyAppWeb, :controller
def me(conn, _params) do
claims = conn.assigns.current_claims
json(conn, %{sub: claims["sub"], email: claims["email"]})
end
end
Understanding the full OAuth 2.0 flow helps here — the bearer token your API receives went through the authorization code flow elsewhere (browser, mobile app, or client credentials for machine-to-machine). Your API only sees the resulting access token and must validate it locally.
Testing the Full Flow Locally
Start your Phoenix app with the required environment variables:
KEYCLOAK_ISSUER=https://auth.example.com/realms/myrealm
KEYCLOAK_CLIENT_ID=my-phoenix-app
KEYCLOAK_CLIENT_SECRET=your-client-secret
mix phx.server
Visit http://localhost:4000/auth/keycloak. You’ll be redirected to Keycloak’s login page. After authenticating, Keycloak redirects back to http://localhost:4000/auth/keycloak/callback, and the callback controller stores the user in the session and sends you to /dashboard.
To test the API plug, obtain an access token using the password grant (for local dev only — don’t use this grant in production):
curl -X POST
https://auth.example.com/realms/myrealm/protocol/openid-connect/token
-d "grant_type=password&client_id=my-phoenix-app&client_secret=your-secret&username=testuser&password=testpass&scope=openid"
Use the returned access_token value as a bearer token:
curl -H "Authorization: Bearer <access_token>" http://localhost:4000/api/me
If token validation fails, use Skycloak’s JWT Token Analyzer to inspect the claims and confirm the iss, aud, and exp fields match what oidcc expects.
For OpenID Connect debugging when the callback doesn’t arrive or returns unexpected errors, check the Keycloak admin console under Events for the relevant realm — it logs authentication failures with reason codes.
Skipping Keycloak Complexity with Managed Hosting
Running your own Keycloak cluster means handling upgrades, JWKS cache expiry, high-availability configuration, SSL certificate rotation, and realm backup. If you’d rather focus on the Phoenix application, Skycloak’s managed Keycloak hosting gives you a fully managed realm with SLA guarantees, automatic Keycloak version upgrades, and support — so you point your KEYCLOAK_ISSUER at a stable endpoint and ship features instead of operating infrastructure.
Frequently asked questions
How do I add Keycloak authentication to a Phoenix LiveView app?
The session-based flow described here works with LiveView. Call get_session/2 during mount/3 to retrieve the current user from the Plug session. LiveView only has access to the session at mount time, not during subsequent events, so assign what you need to the socket immediately. If the access token expires, redirect the user to /auth/keycloak to re-authenticate. Store tokens in the server-side session, not in JavaScript-accessible cookies, to prevent XSS exposure.
What is the difference between ueberauth_oidcc and ueberauth_oidc?
ueberauth_oidcc is built on the Erlang oidcc library, which runs as an OTP supervision tree and handles provider discovery, JWKS caching, and token validation as managed processes. ueberauth_oidc (without the second c) is a separate, simpler strategy that doesn’t use oidcc under the hood and handles less of the validation automatically. For Keycloak in production, ueberauth_oidcc is the recommended choice because oidcc has stricter spec compliance and better JWKS cache management.
How do I verify Keycloak JWTs in a Phoenix API without Ueberauth?
Use oidcc directly. Start the ProviderConfigurationWorker in your supervision tree pointed at the Keycloak issuer URL, then call :oidcc.validate_jwt/3 with the raw token string and your provider name. The library fetches the JWKS on first call and caches it, so subsequent validations don’t make HTTP requests. This is the approach shown in the TokenVerifier module above. For more detail on the validation algorithm, the Keycloak token validation guide covers introspection as an alternative when you need to check token revocation in real time.
How does PKCE work with Keycloak and Ueberauth?
ueberauth_oidcc generates a code_verifier (a random 43-128 character string), hashes it with SHA-256 to produce the code_challenge, and includes the challenge in the authorization request to Keycloak. When the callback arrives with the authorization code, the library sends the original code_verifier in the token exchange. Keycloak verifies that SHA-256(code_verifier) equals the stored code_challenge before issuing tokens. This prevents authorization code interception attacks. For Keycloak 26.x, PKCE is supported for public clients by default and can be enforced on confidential clients via the client’s Advanced settings.
Can I use Keycloak groups and roles in Phoenix authorization?
Yes. Keycloak includes realm_access.roles and resource_access.{client_id}.roles in the access token by default. After verifying the JWT with oidcc, you can pattern-match on claims["realm_access"]["roles"] to gate access. For group membership, add the groups scope or configure a mapper in the Keycloak client to include groups in the token. The ueberauth_auth struct also surfaces these via auth.extra.raw_info if you need them in the Ueberauth callback flow. Combining Keycloak roles with Phoenix’s authorization layer is a natural fit for SSO implementations that span multiple applications sharing a single realm.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.