Configure Keycloak OAuth2 integration with web applications using OIDC and JWT tokens

Intermediate 45 min Apr 20, 2026 156 views
Ubuntu 24.04 Debian 12 AlmaLinux 9 Rocky Linux 9

Set up Keycloak as an OAuth2 identity provider with OIDC authentication flows. Configure client applications, implement JWT token validation, and secure NGINX reverse proxy with lua-resty-openidc for production web applications.

Prerequisites

  • Keycloak server running and accessible
  • Domain with SSL certificates
  • Basic understanding of OAuth2 and JWT concepts
  • Root or sudo access to web server

What this solves

Keycloak OAuth2 integration provides centralized authentication and authorization for web applications using OpenID Connect (OIDC) protocol. This tutorial shows you how to configure Keycloak as an identity provider, create OAuth2 client applications, implement JWT token validation, and secure NGINX reverse proxy with lua-resty-openidc for production-grade single sign-on (SSO).

Step-by-step configuration

Install required packages

Install OpenResty with lua-resty-openidc module for OAuth2 integration and JWT token handling.

sudo apt update
sudo apt install -y openresty lua-resty-http lua-resty-jwt
wget https://github.com/zmartzone/lua-resty-openidc/archive/refs/tags/v1.7.6.tar.gz
tar xzf v1.7.6.tar.gz
sudo cp -r lua-resty-openidc-1.7.6/lib/resty/* /usr/local/openresty/lualib/resty/
sudo dnf install -y epel-release
sudo dnf install -y openresty lua-resty-http
wget https://github.com/zmartzone/lua-resty-openidc/archive/refs/tags/v1.7.6.tar.gz
tar xzf v1.7.6.tar.gz
sudo cp -r lua-resty-openidc-1.7.6/lib/resty/* /usr/local/openresty/lualib/resty/

Configure Keycloak OAuth2 client

Create a new client application in Keycloak admin console for your web application.

Access Keycloak admin console at https://your-keycloak-server:8443/auth/admin and create a new client:

  • Client ID: webapp-client
  • Client Protocol: openid-connect
  • Access Type: confidential
  • Valid Redirect URIs: https://example.com/auth/callback
  • Web Origins: https://example.com
Note: Save the client secret from the Credentials tab. You'll need this for the NGINX configuration.

Configure NGINX with OpenResty

Set up NGINX configuration with lua-resty-openidc for OAuth2 authentication and JWT token validation.

worker_processes auto;
worker_rlimit_nofile 65535;

events {
    worker_connections 4096;
    use epoll;
    multi_accept on;
}

http {
    include mime.types;
    default_type application/octet-stream;
    
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    
    # Lua package path for resty modules
    lua_package_path "/usr/local/openresty/lualib/?.lua;;";
    lua_shared_dict discovery 1m;
    lua_shared_dict sessions 10m;
    
    # SSL configuration
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;
    
    server {
        listen 443 ssl http2;
        server_name example.com;
        
        ssl_certificate /etc/ssl/certs/example.com.crt;
        ssl_certificate_key /etc/ssl/private/example.com.key;
        
        # OIDC configuration
        set $session_secret "your-32-char-session-secret-key";
        set $oidc_discovery "https://your-keycloak-server:8443/auth/realms/master/.well-known/openid_configuration";
        set $oidc_client_id "webapp-client";
        set $oidc_client_secret "your-client-secret-from-keycloak";
        set $oidc_redirect_uri "https://example.com/auth/callback";
        
        location / {
            access_by_lua_block {
                local opts = {
                    discovery = ngx.var.oidc_discovery,
                    client_id = ngx.var.oidc_client_id,
                    client_secret = ngx.var.oidc_client_secret,
                    redirect_uri = ngx.var.oidc_redirect_uri,
                    scope = "openid email profile",
                    session_secret = ngx.var.session_secret,
                    token_signing_alg_values_expected = { "RS256" },
                    logout_path = "/logout",
                    redirect_after_logout_uri = "https://example.com",
                    refresh_session_interval = 900,
                    access_token_expires_leeway = 300
                }
                
                local res, err = require("resty.openidc").authenticate(opts)
                if err then
                    ngx.log(ngx.ERR, "OIDC authentication error: ", err)
                    ngx.status = 500
                    ngx.say("Authentication error")
                    ngx.exit(500)
                end
                
                -- Set user info headers for backend application
                ngx.req.set_header("X-User", res.id_token.preferred_username or res.id_token.sub)
                ngx.req.set_header("X-User-Email", res.id_token.email or "")
                ngx.req.set_header("X-Access-Token", res.access_token)
            }
            
            # Proxy to your application
            proxy_pass http://127.0.0.1:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
        
        location /auth/callback {
            access_by_lua_block {
                local opts = {
                    discovery = ngx.var.oidc_discovery,
                    client_id = ngx.var.oidc_client_id,
                    client_secret = ngx.var.oidc_client_secret,
                    redirect_uri = ngx.var.oidc_redirect_uri,
                    scope = "openid email profile",
                    session_secret = ngx.var.session_secret
                }
                require("resty.openidc").authenticate(opts)
            }
            
            return 302 https://example.com/;
        }
        
        location /logout {
            access_by_lua_block {
                local opts = {
                    discovery = ngx.var.oidc_discovery,
                    client_id = ngx.var.oidc_client_id,
                    client_secret = ngx.var.oidc_client_secret,
                    redirect_uri = ngx.var.oidc_redirect_uri,
                    session_secret = ngx.var.session_secret,
                    redirect_after_logout_uri = "https://example.com"
                }
                require("resty.openidc").logout(opts)
            }
        }
    }
}

Generate session secret

Create a secure 32-character session secret for encrypting user sessions.

openssl rand -hex 32

Replace your-32-char-session-secret-key in the NGINX configuration with the generated value.

Configure Keycloak realm settings

Set up proper token settings and user attributes in Keycloak for JWT token validation.

In Keycloak admin console, navigate to Realm Settings and configure:

  • Access Token Lifespan: 15 minutes
  • SSO Session Idle: 30 minutes
  • SSO Session Max: 10 hours
  • Access Token Lifespan For Implicit Flow: 15 minutes

Under Client Scopes, ensure the email and profile scopes include the necessary user attributes.

Create backend application JWT validation

Configure your backend application to validate JWT tokens from NGINX headers.

import jwt
import requests
from functools import wraps
from flask import request, jsonify, current_app

class KeycloakJWTValidator:
    def __init__(self, keycloak_url, realm, client_id):
        self.keycloak_url = keycloak_url
        self.realm = realm
        self.client_id = client_id
        self.public_keys = {}
        
    def get_public_keys(self):
        """Fetch public keys from Keycloak JWKS endpoint"""
        jwks_url = f"{self.keycloak_url}/auth/realms/{self.realm}/protocol/openid-connect/certs"
        response = requests.get(jwks_url)
        jwks = response.json()
        
        for key in jwks['keys']:
            kid = key['kid']
            self.public_keys[kid] = jwt.algorithms.RSAAlgorithm.from_jwk(key)
        
        return self.public_keys
    
    def validate_token(self, token):
        """Validate JWT token and return user claims"""
        try:
            # Decode token header to get key ID
            unverified_header = jwt.get_unverified_header(token)
            kid = unverified_header['kid']
            
            # Get public key for token validation
            if kid not in self.public_keys:
                self.get_public_keys()
            
            public_key = self.public_keys.get(kid)
            if not public_key:
                raise jwt.InvalidTokenError("Public key not found")
            
            # Validate and decode token
            decoded_token = jwt.decode(
                token,
                public_key,
                algorithms=['RS256'],
                audience=self.client_id,
                options={"verify_exp": True, "verify_aud": True}
            )
            
            return decoded_token
            
        except jwt.ExpiredSignatureError:
            raise Exception("Token has expired")
        except jwt.InvalidAudienceError:
            raise Exception("Invalid token audience")
        except jwt.InvalidTokenError as e:
            raise Exception(f"Invalid token: {str(e)}")

def require_auth(validator):
    """Decorator to require valid JWT token"""
    def decorator(f):
        @wraps(f)
        def decorated_function(*args, **kwargs):
            token = request.headers.get('X-Access-Token')
            if not token:
                return jsonify({'error': 'No access token provided'}), 401
            
            try:
                user_claims = validator.validate_token(token)
                request.user = user_claims
                return f(*args, **kwargs)
            except Exception as e:
                return jsonify({'error': str(e)}), 401
        
        return decorated_function
    return decorator

Usage example

validator = KeycloakJWTValidator( keycloak_url="https://your-keycloak-server:8443", realm="master", client_id="webapp-client" ) @app.route('/api/protected') @require_auth(validator) def protected_endpoint(): user_email = request.headers.get('X-User-Email') return jsonify({ 'message': 'Access granted', 'user': request.user.get('preferred_username'), 'email': user_email })

Start and enable services

Start OpenResty with the OAuth2 configuration and enable it to start on boot.

sudo systemctl start openresty
sudo systemctl enable openresty
sudo systemctl status openresty

Configure firewall rules

Open necessary ports for HTTPS traffic and backend application communication.

sudo ufw allow 443/tcp
sudo ufw allow 80/tcp
sudo ufw reload
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --reload

Verify your setup

Test the OAuth2 authentication flow and JWT token validation.

# Test NGINX configuration
sudo openresty -t

Check OpenResty status

sudo systemctl status openresty

Test OAuth2 redirect (should redirect to Keycloak)

curl -I https://example.com/

Verify Keycloak OIDC discovery endpoint

curl -s https://your-keycloak-server:8443/auth/realms/master/.well-known/openid_configuration | jq .

Access your application at https://example.com. You should be redirected to Keycloak for authentication, then redirected back to your application after successful login.

Advanced configuration options

Configure token refresh

Set up automatic token refresh to maintain user sessions without re-authentication.

local function refresh_access_token(opts, session)
    local http = require("resty.http")
    local httpc = http.new()
    
    local res, err = httpc:request_uri(opts.token_endpoint, {
        method = "POST",
        body = ngx.encode_args({
            grant_type = "refresh_token",
            refresh_token = session.refresh_token,
            client_id = opts.client_id,
            client_secret = opts.client_secret
        }),
        headers = {
            ["Content-Type"] = "application/x-www-form-urlencoded"
        }
    })
    
    if not res or res.status ~= 200 then
        return nil, "Failed to refresh token"
    end
    
    local token_response = require("cjson").decode(res.body)
    session.access_token = token_response.access_token
    session.refresh_token = token_response.refresh_token or session.refresh_token
    
    return session, nil
end

Configure role-based access control

Add role-based authorization using Keycloak realm roles and client roles.

local function check_user_roles(required_roles, user_roles)
    for _, required_role in ipairs(required_roles) do
        local has_role = false
        for _, user_role in ipairs(user_roles or {}) do
            if user_role == required_role then
                has_role = true
                break
            end
        end
        if not has_role then
            return false
        end
    end
    return true
end

-- Usage in location block
location /admin {
    access_by_lua_block {
        local opts = {
            discovery = ngx.var.oidc_discovery,
            client_id = ngx.var.oidc_client_id,
            client_secret = ngx.var.oidc_client_secret,
            redirect_uri = ngx.var.oidc_redirect_uri,
            scope = "openid email profile roles",
            session_secret = ngx.var.session_secret
        }
        
        local res, err = require("resty.openidc").authenticate(opts)
        if err then
            ngx.status = 500
            ngx.say("Authentication error")
            ngx.exit(500)
        end
        
        -- Check for admin role
        local realm_roles = res.id_token.realm_access and res.id_token.realm_access.roles or {}
        if not check_user_roles({"admin"}, realm_roles) then
            ngx.status = 403
            ngx.say("Access denied: Admin role required")
            ngx.exit(403)
        end
    }
    
    proxy_pass http://127.0.0.1:8080;
}

Common issues

SymptomCauseFix
"Invalid redirect URI" errorKeycloak client redirect URI mismatchUpdate client Valid Redirect URIs in Keycloak admin console
"Failed to retrieve discovery document"Keycloak URL or realm name incorrectVerify $oidc_discovery URL returns valid JSON
"Session secret too short"Session secret less than 32 charactersGenerate new 32-character secret with openssl rand -hex 32
"Token signature verification failed"Client secret mismatch or algorithm issueCheck client secret in Keycloak and verify RS256 algorithm
"lua-resty-openidc not found"Module not installed correctlyVerify module files in /usr/local/openresty/lualib/resty/
Backend receives no user headersHeaders not passed through proxyCheck proxy_set_header directives in NGINX config

Security considerations

Implement additional security measures for production environments.

Security Warning: Always use HTTPS for OAuth2 flows. Store client secrets securely and rotate them regularly. Implement proper session timeout and token validation.
  • Use strong session secrets (32+ random characters)
  • Implement proper CORS policies for your application domain
  • Set appropriate token lifespans (15 minutes for access tokens)
  • Enable Keycloak security headers and brute force protection
  • Monitor authentication logs for suspicious activity
  • Implement rate limiting on authentication endpoints

Next steps

Running this in production?

Want this handled for you? Setting up OAuth2 integration once is straightforward. Keeping it patched, monitored, backed up and tuned across environments is the harder part. See how we run infrastructure like this for European SaaS and e-commerce teams.

Need help?

Don't want to manage this yourself?

We handle infrastructure security hardening for businesses that depend on uptime. From initial setup to ongoing operations.