Webhook Integration for Developers
A comprehensive guide for developers building custom webhook endpoints to receive Keycloak authentication events and logs from Skycloak’s SIEM Integration.
Overview
Webhooks provide a flexible way to receive real-time Keycloak events in your application. This guide covers:
- Building a webhook endpoint
- Understanding the payload format
- Implementing authentication
- Testing and debugging
- Production best practices
Quick Start
Minimal Webhook Endpoint
Here’s a minimal Express.js endpoint that receives Skycloak events:
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/skycloak-events', (req, res) => {
const { source, type, timestamp, event_count, events } = req.body;
console.log(`Received ${event_count} events from ${source}`);
// Process events
events.forEach(event => {
console.log(`Event: ${event.event_type} at ${event.timestamp}`);
});
// Return 200 to acknowledge receipt
res.status(200).json({ received: true });
});
app.listen(3000, () => {
console.log('Webhook server running on port 3000');
});Payload Format
Request Structure
Skycloak sends POST requests with the following structure:
{
"source": "skycloak",
"type": "keycloak_events",
"timestamp": "2025-01-15T14:30:00Z",
"event_count": 1,
"events": [
{
"type": "LOGIN",
"timestamp": "2025-01-15T14:29:45Z",
"realm": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "production"
},
"auth": {
"client_id": "web-app",
"redirect_uri": "https://app.example.com/callback",
"method": "openid-connect",
"identity_provider": "google"
},
"user": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"username": "[email protected]",
"ip_address": "203.0.113.42"
},
"session": {
"id": "7f3e9c8a-1b4d-4e5f-a6c7-8d9e0f1a2b3c"
},
"geo": {
"country": "United States",
"country_code": "US",
"city": "San Francisco",
"latitude": 37.7749,
"longitude": -122.4194
},
"metadata": {
"event_type": "keycloak_events",
"workspace_id": "f1e2d3c4-b5a6-4789-a012-345678901234",
"cluster_id": "a1b2c3d4-e5f6-4789-a012-345678901234",
"source": "skycloak"
},
"details": {
"grant_type": "authorization_code",
"event_category": "user",
"is_m2_m": false
}
}
]
}Admin Event Example
For admin events (realm configuration changes, user management, etc.), the payload includes operation and resource information:
{
"source": "skycloak",
"type": "keycloak_events",
"timestamp": "2025-01-15T14:30:00Z",
"event_count": 1,
"events": [
{
"type": "CREATE",
"timestamp": "2025-01-15T14:29:45Z",
"realm": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "production"
},
"user": {
"ip_address": "203.0.113.42"
},
"resource": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"type": "USER",
"path": "users/123e4567-e89b-12d3-a456-426614174000"
},
"geo": {
"country": "United States",
"country_code": "US",
"city": "San Francisco",
"latitude": 37.7749,
"longitude": -122.4194
},
"metadata": {
"event_type": "keycloak_events",
"workspace_id": "f1e2d3c4-b5a6-4789-a012-345678901234",
"cluster_id": "a1b2c3d4-e5f6-4789-a012-345678901234",
"source": "skycloak"
},
"details": {
"event_category": "admin",
"operation_type": "CREATE",
"resource_type": "USER"
}
}
]
}Admin Event Fields: Admin events include a resource object with the affected resource’s type, path, and ID. The details object contains the operation type (CREATE, UPDATE, DELETE, ACTION) and resource type.
Payload Fields
| Field | Type | Description |
|---|---|---|
source |
string | Always “skycloak” |
type |
string | Event type: “keycloak_events”, “application_logs”, or “security_logs” |
timestamp |
string | ISO 8601 timestamp when webhook was sent |
event_count |
number | Number of events in the batch |
events |
array | Array of event objects (see below) |
Event Object Fields
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Event type (LOGIN, LOGOUT, REGISTER, CODE_TO_TOKEN, etc.) |
timestamp |
string | Yes | When the event occurred (ISO 8601) |
error |
string | No | Error code (for error events like LOGIN_ERROR) |
realm |
object | No | Realm information (id, name) |
auth |
object | No | Authentication info (client_id, redirect_uri, method, identity_provider) |
user |
object | No | User information (id, username, ip_address) |
session |
object | No | Session information (id) |
resource |
object | No | For admin events - resource info (id, type, path) |
geo |
object | No | IP geolocation data (auto-enriched, see below) |
metadata |
object | Yes | Event metadata (event_type, workspace_id, cluster_id, source) |
details |
object | No | Additional event-specific data (grant_type, event_category, is_m2_m, etc.) |
Metadata Fields
| Field | Type | Description |
|---|---|---|
event_type |
string | Type of data: “keycloak_events”, “application_logs”, “security_logs” |
workspace_id |
string | Skycloak workspace UUID |
cluster_id |
string | Skycloak cluster UUID |
source |
string | Always “skycloak” |
Geo Fields
The geo object provides IP geolocation data when the user’s IP address is available:
| Field | Type | Description |
|---|---|---|
country |
string | Full country name (e.g., “United States”, “United Kingdom”, “Germany”) |
country_code |
string | Two-letter ISO 3166-1 alpha-2 country code (e.g., “US”, “GB”, “DE”) |
city |
string | City name (e.g., “San Francisco”, “London”) |
latitude |
number | Geographic latitude coordinate |
longitude |
number | Geographic longitude coordinate |
Note: The geo field is automatically enriched from the user’s IP address when available. It may be null or absent for events without IP information or when geolocation data is unavailable.
Implementation Examples
Node.js with Express
Complete example with authentication and error handling:
const express = require('express');
const crypto = require('crypto');
const app = express();
// Environment variables
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'your-secret-token';
const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json({ limit: '10mb' }));
// Bearer token authentication
const authenticateWebhook = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid authorization header' });
}
const token = authHeader.substring(7);
if (token !== WEBHOOK_SECRET) {
return res.status(403).json({ error: 'Invalid token' });
}
next();
};
// Webhook endpoint
app.post('/webhooks/skycloak-events', authenticateWebhook, async (req, res) => {
try {
const { source, type, timestamp, event_count, events } = req.body;
// Validate payload
if (source !== 'skycloak' || type !== 'keycloak_events') {
return res.status(400).json({ error: 'Invalid payload source or type' });
}
if (!Array.isArray(events) || events.length === 0) {
return res.status(400).json({ error: 'Events array is required' });
}
console.log(`[${timestamp}] Received ${event_count} events`);
// Process events asynchronously
const results = await Promise.allSettled(
events.map(event => processEvent(event))
);
// Log any failures
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
console.error(`Failed to process ${failures.length} events:`, failures);
}
// Always return 200 to acknowledge receipt
// Even if some events failed processing, we received them
res.status(200).json({
received: true,
processed: results.filter(r => r.status === 'fulfilled').length,
failed: failures.length
});
} catch (error) {
console.error('Webhook error:', error);
// Return 200 even on error to avoid retries
res.status(200).json({ received: true, error: error.message });
}
});
async function processEvent(event) {
const { event_type, timestamp, username, ip_address, realm } = event;
console.log(`Processing ${event_type}: user=${username}, ip=${ip_address}, realm=${realm}`);
// Example: Store in database
// await db.events.create({
// type: event_type,
// timestamp: new Date(timestamp),
// user: username,
// data: event
// });
// Example: Send to analytics
// if (event_type === 'LOGIN' || event_type === 'LOGIN_ERROR') {
// await analytics.track({
// event: event_type,
// userId: event.user_id,
// properties: { realm, ip_address }
// });
// }
// Example: Trigger alerts
// if (event_type === 'LOGIN_ERROR' && event.error === 'invalid_user_credentials') {
// await alerting.checkBruteForce(username, ip_address);
// }
}
app.listen(PORT, () => {
console.log(`Webhook server listening on port ${PORT}`);
});Python with Flask
from flask import Flask, request, jsonify
import os
from datetime import datetime
import logging
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET', 'your-secret-token')
def authenticate_webhook():
"""Verify Bearer token"""
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return False
token = auth_header[7:]
return token == WEBHOOK_SECRET
@app.route('/webhooks/skycloak-events', methods=['POST'])
def handle_webhook():
# Authenticate
if not authenticate_webhook():
return jsonify({'error': 'Unauthorized'}), 401
try:
data = request.get_json()
# Validate payload
if data.get('source') != 'skycloak' or data.get('type') != 'keycloak_events':
return jsonify({'error': 'Invalid payload'}), 400
events = data.get('events', [])
logging.info(f"Received {len(events)} events at {data.get('timestamp')}")
# Process events
for event in events:
process_event(event)
return jsonify({'received': True, 'processed': len(events)}), 200
except Exception as e:
logging.error(f"Webhook error: {e}")
# Return 200 to avoid retries
return jsonify({'received': True, 'error': str(e)}), 200
def process_event(event):
"""Process individual event"""
event_type = event.get('event_type')
username = event.get('username')
ip_address = event.get('ip_address')
logging.info(f"Processing {event_type}: user={username}, ip={ip_address}")
# Example: Store in database
# db.session.add(Event(
# type=event_type,
# timestamp=datetime.fromisoformat(event['timestamp']),
# user=username,
# data=event
# ))
# db.session.commit()
# Example: Check for suspicious activity
if event_type == 'LOGIN_ERROR':
check_failed_login_attempts(username, ip_address)
def check_failed_login_attempts(username, ip_address):
"""Example: Alert on multiple failed logins"""
# Implement your logic here
pass
if __name__ == '__main__':
app.run(host='0.0.0.0', port=3000)Go with net/http
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
)
type WebhookPayload struct {
Source string `json:"source"`
Type string `json:"type"`
Timestamp string `json:"timestamp"`
EventCount int `json:"event_count"`
Events []map[string]interface{} `json:"events"`
}
type WebhookResponse struct {
Received bool `json:"received"`
Processed int `json:"processed,omitempty"`
Error string `json:"error,omitempty"`
}
var webhookSecret = os.Getenv("WEBHOOK_SECRET")
func main() {
if webhookSecret == "" {
webhookSecret = "your-secret-token"
}
http.HandleFunc("/webhooks/skycloak-events", handleWebhook)
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
log.Printf("Webhook server listening on port %s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
func handleWebhook(w http.ResponseWriter, r *http.Request) {
// Check method
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Authenticate
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if token != webhookSecret {
http.Error(w, "Invalid token", http.StatusForbidden)
return
}
// Parse payload
var payload WebhookPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
log.Printf("Failed to parse payload: %v", err)
respondJSON(w, WebhookResponse{Received: true, Error: err.Error()}, http.StatusOK)
return
}
// Validate
if payload.Source != "skycloak" || payload.Type != "keycloak_events" {
http.Error(w, "Invalid payload", http.StatusBadRequest)
return
}
log.Printf("[%s] Received %d events", payload.Timestamp, payload.EventCount)
// Process events
processed := 0
for _, event := range payload.Events {
if err := processEvent(event); err != nil {
log.Printf("Failed to process event: %v", err)
} else {
processed++
}
}
respondJSON(w, WebhookResponse{Received: true, Processed: processed}, http.StatusOK)
}
func processEvent(event map[string]interface{}) error {
eventType, _ := event["event_type"].(string)
username, _ := event["username"].(string)
ipAddress, _ := event["ip_address"].(string)
log.Printf("Processing %s: user=%s, ip=%s", eventType, username, ipAddress)
// Example: Store in database
// if err := db.InsertEvent(event); err != nil {
// return fmt.Errorf("database error: %w", err)
// }
// Example: Send alert
// if eventType == "LOGIN_ERROR" {
// checkBruteForce(username, ipAddress)
// }
return nil
}
func respondJSON(w http.ResponseWriter, data interface{}, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}Authentication
Skycloak supports two authentication methods for webhooks:
Bearer Token (Recommended)
Simple and secure for most use cases:
// Verify Bearer token
const authHeader = req.headers.authorization;
const token = authHeader.substring(7); // Remove "Bearer "
if (token !== WEBHOOK_SECRET) {
return res.status(403).json({ error: 'Invalid token' });
}Configuration in Skycloak:
- Authentication Type: Bearer Token
- Auth Token: Your secure random token (e.g.,
sk_live_abc123...)
Basic Authentication
Username and password encoded in Base64:
// Verify Basic auth
const authHeader = req.headers.authorization;
const base64Credentials = authHeader.substring(6); // Remove "Basic "
const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
const [username, password] = credentials.split(':');
if (username !== EXPECTED_USERNAME || password !== EXPECTED_PASSWORD) {
return res.status(403).json({ error: 'Invalid credentials' });
}Configuration in Skycloak:
- Authentication Type: Basic Auth
- Auth Token:
username:password(Skycloak will Base64 encode it)
Testing Your Webhook
Local Development with ngrok
- Start your webhook server locally:
node webhook-server.js
# Server running on http://localhost:3000- Expose it with ngrok:
ngrok http 3000
# Forwarding https://abc123.ngrok.io -> http://localhost:3000- Use the ngrok HTTPS URL in Skycloak:
https://abc123.ngrok.io/webhooks/skycloak-events
Testing with curl
Send a test payload to your endpoint:
curl -X POST https://your-domain.com/webhooks/skycloak-events \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-secret-token" \
-d '{
"source": "skycloak",
"type": "keycloak_events",
"timestamp": "2025-01-15T14:30:00Z",
"event_count": 1,
"events": [
{
"timestamp": "2025-01-15T14:29:45Z",
"event_type": "LOGIN",
"realm": "production",
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"username": "[email protected]",
"ip_address": "203.0.113.42",
"client_id": "web-app"
}
]
}'Testing with Postman
- Create new POST request
- URL:
https://your-domain.com/webhooks/skycloak-events - Headers:
-
Content-Type:application/json -
Authorization:Bearer your-secret-token
-
- Body (raw JSON): Copy the test payload from above
- Send and verify 200 response
Response Codes
Your webhook endpoint should return appropriate HTTP status codes:
| Code | Meaning | Skycloak Behavior |
|---|---|---|
200-299 |
Success | Event marked as delivered |
400-499 |
Client error | Event marked as failed (no retry) |
500-599 |
Server error | Will retry with exponential backoff |
Timeout |
No response after 30s | Will retry |
Important: Always return 200 to acknowledge receipt, even if processing fails. Handle errors internally and retry later if needed.
Error Handling
Idempotency
Skycloak may send the same event multiple times due to retries. Implement idempotency:
const processedEvents = new Set();
async function processEvent(event) {
// Create unique ID from event
const eventId = `${event.event_type}-${event.timestamp}-${event.user_id}`;
// Skip if already processed
if (processedEvents.has(eventId)) {
console.log(`Skipping duplicate event: ${eventId}`);
return;
}
// Process event
await saveToDatabase(event);
// Mark as processed
processedEvents.add(eventId);
}For production, use a database or cache (Redis) instead of in-memory Set.
Retry Logic
If processing fails, queue for retry:
const queue = require('bull'); // Or your preferred queue
const eventQueue = queue('events');
app.post('/webhooks/skycloak-events', async (req, res) => {
const { events } = req.body;
// Acknowledge immediately
res.status(200).json({ received: true });
// Queue for processing
for (const event of events) {
await eventQueue.add(event, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000
}
});
}
});
// Process events asynchronously
eventQueue.process(async (job) => {
await processEvent(job.data);
});Production Best Practices
1. Use HTTPS
Always use HTTPS in production:
const https = require('https');
const fs = require('fs');
const options = {
key: fs.readFileSync('private-key.pem'),
cert: fs.readFileSync('certificate.pem')
};
https.createServer(options, app).listen(443);Or use a reverse proxy (nginx, Cloudflare) to handle SSL.
2. Rate Limiting
Protect your endpoint from abuse:
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: 'Too many requests'
});
app.use('/webhooks', limiter);3. Request Size Limits
Limit payload size to prevent DoS:
app.use(express.json({ limit: '10mb' }));4. Logging
Log all webhook requests for debugging:
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'webhooks.log' })
]
});
app.post('/webhooks/skycloak-events', (req, res) => {
logger.info('Webhook received', {
timestamp: new Date().toISOString(),
event_count: req.body.event_count,
source_ip: req.ip
});
// Process...
});5. Monitoring
Monitor webhook health:
const prometheus = require('prom-client');
const webhookCounter = new prometheus.Counter({
name: 'webhook_requests_total',
help: 'Total webhook requests'
});
const webhookDuration = new prometheus.Histogram({
name: 'webhook_duration_seconds',
help: 'Webhook processing duration'
});
app.post('/webhooks/skycloak-events', async (req, res) => {
const start = Date.now();
webhookCounter.inc();
// Process...
webhookDuration.observe((Date.now() - start) / 1000);
res.status(200).json({ received: true });
});6. Secrets Management
Never hardcode secrets:
// Bad
const WEBHOOK_SECRET = 'my-secret-token';
// Good - Use environment variables
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
// Better - Use secrets manager
const AWS = require('aws-sdk');
const secrets = new AWS.SecretsManager();
async function getSecret() {
const data = await secrets.getSecretValue({
SecretId: 'skycloak/webhook-secret'
}).promise();
return data.SecretString;
}Use Cases
1. Security Monitoring
Alert on suspicious login patterns:
async function processEvent(event) {
if (event.event_type === 'LOGIN_ERROR') {
const recentFailures = await countRecentFailedLogins(
event.username,
event.ip_address
);
if (recentFailures >= 5) {
await sendAlert({
type: 'brute_force_detected',
username: event.username,
ip: event.ip_address,
count: recentFailures
});
}
}
}2. User Analytics
Track authentication metrics:
async function processEvent(event) {
if (event.event_type === 'LOGIN') {
await analytics.track({
userId: event.user_id,
event: 'User Login',
properties: {
realm: event.realm,
client: event.client_id,
ip: event.ip_address,
timestamp: event.timestamp
}
});
}
}3. Compliance Logging
Store events for audit trail:
async function processEvent(event) {
await db.auditLog.create({
timestamp: new Date(event.timestamp),
event_type: event.event_type,
user_id: event.user_id,
username: event.username,
ip_address: event.ip_address,
realm: event.realm,
details: event.details,
retention_until: new Date(Date.now() + 7 * 365 * 24 * 60 * 60 * 1000) // 7 years
});
}4. Real-time Notifications
Notify users of account activity:
async function processEvent(event) {
if (event.event_type === 'LOGIN' && isUnusualLocation(event.ip_address)) {
await sendEmail({
to: event.username,
subject: 'New login from unusual location',
body: `Someone logged into your account from ${event.ip_address}`
});
}
}Troubleshooting
Webhook Not Receiving Events
- Check URL is publicly accessible:
curl -I https://your-domain.com/webhooks/skycloak-events- Verify SSL certificate (must be valid):
curl -v https://your-domain.com/webhooks/skycloak-eventsCheck firewall rules - Allow inbound HTTPS (port 443)
Review Skycloak destination status - Check error message in UI
Authentication Failures
- Verify token matches - Check environment variables
-
Check header format - Should be
Authorization: Bearer <token> - Test with curl - Verify manually with correct headers
Events Not Processing
- Check logs - Look for errors in application logs
-
Verify payload structure - Log
req.bodyto debug - Test idempotency - Ensure duplicate events aren’t causing issues
Performance Issues
- Process events asynchronously - Don’t block webhook response
- Use queue for heavy processing - Bull, RabbitMQ, SQS
- Scale horizontally - Run multiple webhook servers behind load balancer
- Optimize database queries - Use indexes, batch inserts
Security Checklist
- ✅ HTTPS only (no HTTP in production)
- ✅ Bearer token or Basic auth enabled
- ✅ Secrets stored in environment variables or secrets manager
- ✅ Request size limits enforced
- ✅ Rate limiting configured
- ✅ Input validation on all fields
- ✅ Logging enabled (but not logging secrets)
- ✅ Monitoring and alerting configured
- ✅ Error handling prevents sensitive data exposure
- ✅ Webhook endpoint not exposed in public documentation
Next Steps
- Set up your webhook endpoint
- Configure in Skycloak SIEM Integration
- Test with sample events
- Monitor webhook health
- Implement your use case (security alerts, analytics, compliance)
Support
For webhook integration questions: