Webhook Integration for Developers

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

  1. Start your webhook server locally:
node webhook-server.js
# Server running on http://localhost:3000
  1. Expose it with ngrok:
ngrok http 3000
# Forwarding https://abc123.ngrok.io -> http://localhost:3000
  1. 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

  1. Create new POST request
  2. URL: https://your-domain.com/webhooks/skycloak-events
  3. Headers:
    • Content-Type: application/json
    • Authorization: Bearer your-secret-token
  4. Body (raw JSON): Copy the test payload from above
  5. 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

  1. Check URL is publicly accessible:
curl -I https://your-domain.com/webhooks/skycloak-events
  1. Verify SSL certificate (must be valid):
curl -v https://your-domain.com/webhooks/skycloak-events
  1. Check firewall rules - Allow inbound HTTPS (port 443)

  2. Review Skycloak destination status - Check error message in UI

Authentication Failures

  1. Verify token matches - Check environment variables
  2. Check header format - Should be Authorization: Bearer <token>
  3. Test with curl - Verify manually with correct headers

Events Not Processing

  1. Check logs - Look for errors in application logs
  2. Verify payload structure - Log req.body to debug
  3. Test idempotency - Ensure duplicate events aren’t causing issues

Performance Issues

  1. Process events asynchronously - Don’t block webhook response
  2. Use queue for heavy processing - Bull, RabbitMQ, SQS
  3. Scale horizontally - Run multiple webhook servers behind load balancer
  4. 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: