Skip to main content
This guide covers implementing OpenID Connect (OIDC) authentication flows with Zitadel, including authorization requests, token handling, and callback processing.

Prerequisites

  • Zitadel Ruby SDK installed and configured
  • An OIDC application created in Zitadel
  • Understanding of OAuth2/OIDC concepts
  • Required permissions: session.read, session.link

OIDC Flow Overview

Zitadel supports standard OIDC flows:
  1. Authorization Code Flow: For web applications (most secure)
  2. Authorization Code + PKCE: For SPAs and mobile apps
  3. Device Authorization: For devices with limited input capabilities

Working with Auth Requests

Get Auth Request Details

When your application redirects users to Zitadel for authentication, they are redirected back with an auth request ID. You can retrieve the details:
require 'zitadel-client'

client = Zitadel::Client::Zitadel.with_access_token(
  "https://example.zitadel.cloud",
  "your_access_token"
)

# Get auth request details
request = Zitadel::Client::OIDCServiceGetAuthRequestRequest.new(
  auth_request_id: "auth_request_id_from_redirect"
)

begin
  response = client.oidc.get_auth_request(request)
  
  puts "Auth Request Details:"
  puts "  Client ID: #{response.auth_request.client_id}"
  puts "  Redirect URI: #{response.auth_request.redirect_uri}"
  puts "  Scopes: #{response.auth_request.scope.join(', ')}"
  puts "  Prompt: #{response.auth_request.prompt}"
  
  if response.auth_request.login_hint
    puts "  Login Hint: #{response.auth_request.login_hint}"
  end
  
rescue Zitadel::Client::ApiError => e
  puts "Error: #{e.message}"
end
Auth requests contain the parameters from the original OAuth2/OIDC authorization request made by your application.

Creating Callbacks

Complete Auth Request and Get Callback URL

After authenticating the user (via session), create a callback to finalize the OIDC flow:
# First, create or retrieve a session for the user
session_request = Zitadel::Client::SessionServiceCreateSessionRequest.new(
  checks: {
    user: {
      login_name: "[email protected]"
    },
    password: {
      password: "user_password"
    }
  },
  lifetime: "3600s"
)

session_response = client.sessions.create_session(session_request)
session_id = session_response.session_id
session_token = session_response.session_token

# Create callback for the auth request
callback_request = Zitadel::Client::OIDCServiceCreateCallbackRequest.new(
  auth_request_id: "auth_request_id_from_redirect",
  session: {
    session_id: session_id,
    session_token: session_token
  }
)

callback_response = client.oidc.create_callback(callback_request)

puts "Callback URL: #{callback_response.callback_url}"
puts "Redirect user to this URL to complete authentication"

# The callback URL contains the authorization code
# User's browser should be redirected to this URL
# Example: https://myapp.com/callback?code=xyz&state=abc
1
User initiates login
2
Your app redirects to Zitadel’s authorization endpoint with client_id, redirect_uri, scope, etc.
3
Zitadel redirects to your app
4
Zitadel redirects back with an auth_request_id parameter.
5
Get auth request details
6
Use get_auth_request to retrieve the authentication parameters.
7
Authenticate the user
8
Create a session with user credentials (or use existing session).
9
Create callback
10
Use create_callback linking the auth request with the session.
11
Redirect user
12
Redirect the user’s browser to the callback URL.
13
Exchange code for tokens
14
Your app exchanges the authorization code for access/ID tokens via standard OAuth2 token endpoint.

Device Authorization Flow

Get Device Authorization Request

For devices with limited input (like smart TVs), use the device authorization flow:
# User enters a code on their device
# Device displays: "Go to https://example.zitadel.cloud/device"
# "Enter code: ABCD-EFGH"

# On your backend, retrieve the device auth request
request = Zitadel::Client::OIDCServiceGetDeviceAuthorizationRequestRequest.new(
  user_code: "ABCD-EFGH" # Code entered by user
)

begin
  response = client.oidc.get_device_authorization_request(request)
  
  puts "Device Authorization Request:"
  puts "  Client ID: #{response.client_id}"
  puts "  Scopes: #{response.scope.join(', ')}"
  puts "  Device Authorization ID: #{response.device_authorization_id}"
  
rescue Zitadel::Client::ApiError => e
  if e.code == 404
    puts "Invalid user code"
  else
    puts "Error: #{e.message}"
  end
end

Authorize or Deny Device Authorization

After the user authenticates on a separate device:
# Authorize the device request
authorize_request = Zitadel::Client::OIDCServiceAuthorizeOrDenyDeviceAuthorizationRequest.new(
  device_authorization_id: "device_auth_id_from_previous_step",
  session: {
    session_id: "user_session_id",
    session_token: "user_session_token"
  },
  authorized: true # Set to false to deny
)

begin
  client.oidc.authorize_or_deny_device_authorization(authorize_request)
  puts "Device authorized successfully"
  # The device can now poll the token endpoint to get tokens
rescue Zitadel::Client::ApiError => e
  puts "Authorization failed: #{e.message}"
end
After authorization, the device polls the token endpoint until it receives tokens or the request expires.

Complete OIDC Integration Example

Web Application Integration

require 'zitadel-client'
require 'sinatra'
require 'securerandom'

# Initialize Zitadel client
client = Zitadel::Client::Zitadel.with_client_credentials(
  "https://example.zitadel.cloud",
  "your_client_id",
  "your_client_secret"
)

# Store in session or database
SESSION_STORE = {}

# Initiate OIDC login
get '/login' do
  state = SecureRandom.hex(16)
  nonce = SecureRandom.hex(16)
  
  # Store state for CSRF protection
  SESSION_STORE[state] = { nonce: nonce, created_at: Time.now }
  
  # Redirect to Zitadel authorization endpoint
  auth_params = {
    client_id: ENV['ZITADEL_CLIENT_ID'],
    redirect_uri: 'https://myapp.com/callback',
    response_type: 'code',
    scope: 'openid email profile',
    state: state,
    nonce: nonce
  }
  
  auth_url = "https://example.zitadel.cloud/oauth/v2/authorize?#{URI.encode_www_form(auth_params)}"
  redirect auth_url
end

# Handle callback
get '/callback' do
  auth_request_id = params['authRequest']
  state = params['state']
  
  # Verify state (CSRF protection)
  unless SESSION_STORE[state]
    halt 400, "Invalid state parameter"
  end
  
  begin
    # Get auth request details
    auth_request = Zitadel::Client::OIDCServiceGetAuthRequestRequest.new(
      auth_request_id: auth_request_id
    )
    auth_response = client.oidc.get_auth_request(auth_request)
    
    # In a real app, you would:
    # 1. Show login form
    # 2. Verify credentials
    # 3. Create session
    
    # For this example, assume user authenticated
    session_request = Zitadel::Client::SessionServiceCreateSessionRequest.new(
      checks: {
        user: {
          login_name: params['username'] # From login form
        },
        password: {
          password: params['password'] # From login form
        }
      },
      lifetime: "3600s"
    )
    
    session_response = client.sessions.create_session(session_request)
    
    # Create callback
    callback_request = Zitadel::Client::OIDCServiceCreateCallbackRequest.new(
      auth_request_id: auth_request_id,
      session: {
        session_id: session_response.session_id,
        session_token: session_response.session_token
      }
    )
    
    callback_response = client.oidc.create_callback(callback_request)
    
    # Redirect to callback URL (contains authorization code)
    redirect callback_response.callback_url
    
  rescue Zitadel::Client::ApiError => e
    halt 500, "Authentication failed: #{e.message}"
  end
end

# After your app receives the code at its redirect_uri,
# exchange it for tokens using standard OAuth2 token endpoint
post '/token-exchange' do
  require 'net/http'
  require 'json'
  
  code = params['code']
  
  uri = URI('https://example.zitadel.cloud/oauth/v2/token')
  request = Net::HTTP::Post.new(uri)
  request.basic_auth(ENV['ZITADEL_CLIENT_ID'], ENV['ZITADEL_CLIENT_SECRET'])
  request.set_form_data(
    grant_type: 'authorization_code',
    code: code,
    redirect_uri: 'https://myapp.com/callback'
  )
  
  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
    http.request(request)
  end
  
  if response.is_a?(Net::HTTPSuccess)
    tokens = JSON.parse(response.body)
    
    # Store tokens securely
    # tokens['access_token']
    # tokens['id_token']
    # tokens['refresh_token']
    
    { success: true, tokens: tokens }.to_json
  else
    halt 500, "Token exchange failed: #{response.body}"
  end
end

Device Flow Integration

require 'zitadel-client'
require 'net/http'
require 'json'

client = Zitadel::Client::Zitadel.with_access_token(
  "https://example.zitadel.cloud",
  "your_access_token"
)

class DeviceFlow
  def self.initiate_device_flow(client_id)
    # Device makes request to device authorization endpoint
    uri = URI('https://example.zitadel.cloud/oauth/v2/device_authorization')
    request = Net::HTTP::Post.new(uri)
    request.set_form_data(
      client_id: client_id,
      scope: 'openid email profile'
    )
    
    response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
      http.request(request)
    end
    
    if response.is_a?(Net::HTTPSuccess)
      data = JSON.parse(response.body)
      
      puts "Device Code Flow Initiated:"
      puts "  Visit: #{data['verification_uri']}"
      puts "  Enter code: #{data['user_code']}"
      puts "  Expires in: #{data['expires_in']} seconds"
      
      {
        device_code: data['device_code'],
        user_code: data['user_code'],
        verification_uri: data['verification_uri'],
        interval: data['interval'] || 5
      }
    else
      { error: "Failed to initiate device flow: #{response.body}" }
    end
  end
  
  def self.authorize_device(client, user_code, username, password)
    # User visits verification URI and enters code
    # Backend retrieves device authorization
    get_request = Zitadel::Client::OIDCServiceGetDeviceAuthorizationRequestRequest.new(
      user_code: user_code
    )
    
    device_auth = client.oidc.get_device_authorization_request(get_request)
    
    # Create session for user
    session_request = Zitadel::Client::SessionServiceCreateSessionRequest.new(
      checks: {
        user: { login_name: username },
        password: { password: password }
      },
      lifetime: "3600s"
    )
    
    session_response = client.sessions.create_session(session_request)
    
    # Authorize the device
    authorize_request = Zitadel::Client::OIDCServiceAuthorizeOrDenyDeviceAuthorizationRequest.new(
      device_authorization_id: device_auth.device_authorization_id,
      session: {
        session_id: session_response.session_id,
        session_token: session_response.session_token
      },
      authorized: true
    )
    
    client.oidc.authorize_or_deny_device_authorization(authorize_request)
    
    { success: true, message: "Device authorized" }
  end
  
  def self.poll_for_token(client_id, device_code, interval)
    # Device polls token endpoint
    uri = URI('https://example.zitadel.cloud/oauth/v2/token')
    
    loop do
      request = Net::HTTP::Post.new(uri)
      request.set_form_data(
        grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
        device_code: device_code,
        client_id: client_id
      )
      
      response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
        http.request(request)
      end
      
      if response.is_a?(Net::HTTPSuccess)
        tokens = JSON.parse(response.body)
        return { success: true, tokens: tokens }
      end
      
      error = JSON.parse(response.body)
      
      case error['error']
      when 'authorization_pending'
        # User hasn't authorized yet, keep polling
        sleep interval
      when 'slow_down'
        # Increase polling interval
        interval += 5
        sleep interval
      else
        # Error occurred
        return { success: false, error: error['error'] }
      end
    end
  end
end

# Usage
device_data = DeviceFlow.initiate_device_flow(ENV['ZITADEL_CLIENT_ID'])

if device_data[:error]
  puts device_data[:error]
else
  # Display to user
  puts "Go to #{device_data[:verification_uri]}"
  puts "Enter code: #{device_data[:user_code]}"
  
  # Meanwhile, user authorizes on another device:
  # DeviceFlow.authorize_device(client, device_data[:user_code], '[email protected]', 'password')
  
  # Device polls for tokens
  result = DeviceFlow.poll_for_token(
    ENV['ZITADEL_CLIENT_ID'],
    device_data[:device_code],
    device_data[:interval]
  )
  
  if result[:success]
    puts "Tokens received:"
    puts "  Access Token: #{result[:tokens]['access_token']}"
    puts "  ID Token: #{result[:tokens]['id_token']}"
  else
    puts "Error: #{result[:error]}"
  end
end

Token Management

Validating ID Tokens

require 'jwt'
require 'net/http'
require 'json'

def get_jwks(issuer)
  # Fetch JWKS from Zitadel
  uri = URI("#{issuer}/.well-known/openid-configuration")
  response = Net::HTTP.get_response(uri)
  config = JSON.parse(response.body)
  
  jwks_uri = URI(config['jwks_uri'])
  jwks_response = Net::HTTP.get_response(jwks_uri)
  JSON.parse(jwks_response.body)
end

def validate_id_token(id_token, client_id, issuer)
  jwks = get_jwks(issuer)
  
  # Decode and verify token
  decoded = JWT.decode(
    id_token,
    nil,
    true,
    {
      algorithms: ['RS256'],
      iss: issuer,
      aud: client_id,
      verify_iss: true,
      verify_aud: true,
      jwks: jwks
    }
  )
  
  payload = decoded[0]
  
  {
    valid: true,
    sub: payload['sub'],
    email: payload['email'],
    name: payload['name']
  }
rescue JWT::DecodeError => e
  { valid: false, error: e.message }
end

# Usage
result = validate_id_token(
  id_token,
  ENV['ZITADEL_CLIENT_ID'],
  'https://example.zitadel.cloud'
)

if result[:valid]
  puts "User: #{result[:name]} (#{result[:email]})"
else
  puts "Invalid token: #{result[:error]}"
end

Best Practices

1
Use PKCE for public clients
2
Always use PKCE for SPAs and mobile apps to prevent authorization code interception.
3
Validate state parameter
4
Always validate the state parameter to prevent CSRF attacks.
5
Verify ID tokens
6
Validate ID token signatures and claims before trusting the token.
7
Use short-lived access tokens
8
Configure short lifetimes for access tokens and use refresh tokens for long-lived sessions.
9
Secure token storage
10
Store tokens securely (HttpOnly cookies for web, secure storage for mobile).
11
Handle errors gracefully
12
Implement proper error handling for all OIDC flows.

Next Steps

Build docs developers (and LLMs) love