Greetings, fellow developers. I’ve seen teams master the art of stateless authentication, building beautiful, scalable REST APIs. But the moment the business asks for a real-time feature — a live chat, a notification feed, a collaborative dashboard — a new challenge emerges.
We’re asked to secure a stateful WebSocket connection.
In pet-projects we might have solved this with an internal JWT provider. But in today’s enterprise world, that’s rare. Authentication is almost always delegated to a central, external server: an SSO provider like Keycloak.
This introduces a new, high-stakes problem. How do we validate a token from Keycloak? The obvious answer, token introspection (making an API call to Keycloak for every new connection), is a performance-bottleneck nightmare. It’s slow, it’s fragile, and it doesn’t scale.
Today, we’re building the production-grade solution.
I will walk you through, step-by-step, how to build a fully functional, high-performance WebSocket server in Symfony that is secured by Keycloak. We will not be making any blocking API calls. Instead, we will perform local, cryptographic validation of Keycloak’s JWTs using their public JSON Web Key Set (JWKS). We’ll also build a Just-in-Time (JIT) user provisioner and handle dynamic state like “activity status” using RPC.
This is the blueprint for integrating modern, federated authentication with a scalable, real-time Symfony application.
Our Architecture
The core WebSocket server remains, but the entire authentication layer is custom-built for performance and security.
- The Framework: Symfony
- The WebSocket Server: gos/web-socket-bundle. This remains our workhorse for managing connections, topics, and RPC.
- The Token Validator: firebase/php-jwt . A lightweight, focused, and widely-trusted library for JWT decoding and signature verification.
- The Crypto Engine: phpseclib/phpseclib. This is the secret sauce. We’ll use this to convert Keycloak’s public key components (from its JWKS endpoint) into a usable PEM-formatted public key that firebase/php-jwt can understand.
- The Utilities: symfony/http-client and symfony/cache. We’ll use these to fetch and cache Keycloak’s public keys, so we only hit their API once per hour, not once per connection.
The Authentication Flow
This is the critical part. We are switching from introspection (slow) to local signature validation (fast).
- Client (Browser): The user is redirected to Keycloak, logs in, and is redirected back to our (hypothetical) main application with an Access Token (JWT).
- Client (Browser): The client stores this token. To initiate the real-time connection, it opens a new WebSocket connection, passing the Keycloak token as a query or header parameter.
- Server (GOS Bundle): The WebSocket server receives the connection request.
Server (Our Custom Code): It triggers our new KeycloakJwtSessionProvider service.
KeycloakJwtSessionProvider:
- Extracts the token from the query string.
- Parses the token’s header (without validating) to find the kid (Key ID).
- Asks our new KeycloakJwkProvider service for the public key matching that kid.
- KeycloakJwkProvider:
- Checks its cache for the public keys.
- Cache Miss: It makes one HTTP call to Keycloak’s /certs (JWKS) endpoint.
- It parses the JSON response, uses phpseclib to build PEM keys from the n (modulus) and e (exponent) values, and caches this map of kid => PEM_Key.
- It returns the correct public key.
- KeycloakJwtSessionProvider:
- Uses firebase/php-jwt to locally validate the token’s signature using the public key.
- It also validates the iss (issuer), aud (audience), and exp (expiry) claims.
- If valid, it extracts the sub (Keycloak’s User ID), email, and roles from the token’s payload.
- KeycloakJwtSessionProvider:
- Calls our new UserManager service to “find or create” a local User entity based on the sub and email. This is Just-in-Time (JIT) Provisioning.
- It attaches a lean, minimal set of data (user_id, keycloak_id, roles, email) to the WebSocket connection’s session.
- Server (GOS Bundle): The connection is accepted. Every message is now tied to this authenticated, stateful session.
Project Setup & User Entity
We’ll start with a fresh project and modify our User entity to support external authentication.
Initialize Project
# Create a new Symfony project
symfony new websocket_keycloak
cd websocket_keycloak
# Add our core dependencies
composer require symfony/orm-pack symfony/maker-bundle symfony/security-bundle
# Add our auth/utility stack
composer require firebase/php-jwt phpseclib/phpseclib symfony/http-client symfony/cache
# Add the WebSocket bundle
composer require gos/web-socket-bundle
Configure Database
Set up your .env file’s DATABASE_URL as before and create the database:
php bin/console doctrine:database:create
Create and Modify the User Entity
First, create the base user.
php bin/console make:user
- Class name: User
- Store in database: yes
- Unique field: email
- Hash passwords: no <- IMPORTANT! We are delegating authentication to Keycloak. Our local User entity will not store passwords.
Now, open src/Entity/User.php. We need to make two key changes:
- Remove the PasswordAuthenticatedUserInterface and all password-related properties/methods.
- Add a keycloakId field. This will store the sub claim from the Keycloak token.
Here is the updated src/Entity/User.php:
// src/Entity/User.php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\UserInterface; // We only need this one
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'user')]
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
#[UniqueEntity(fields: ['keycloakId'], message: 'This keycloak ID is already in use')]
class User implements UserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
private ?string $email = null;
// 👇 NEW FIELD
#[ORM\Column(length: 255, unique: true, nullable: true)]
private ?string $keycloakId = null;
/**
* @var list<string> The user roles
*/
#[ORM\Column]
private array $roles = [];
// We have removed getPassword(), setPassword(), and PasswordAuthenticatedUserInterface
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getKeycloakId(): ?string
{
return $this->keycloakId;
}
public function setKeycloakId(?string $keycloakId): static
{
$this->keycloakId = $keycloakId;
return $this;
}
/**
* A visual identifier that represents this user.
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @see UserInterface
* @return list<string>
*/
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER'; // guarantee every user at least has ROLE_USER
return array_unique($roles);
}
/**
* @param list<string> $roles
*/
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
/**
* @see UserInterface
*/
public function eraseCredentials(): void
{
// No credentials to erase
}
}
Create and Run the Migration
Let’s get our database schema updated.
php bin/console make:migration
php bin/console doctrine:migrations:migrate
Our database is now ready to store users provisioned from Keycloak.
Building the Keycloak Validator & User Provisioner
This is where we build the core services that replace a traditional JWT bundle.
Configure Keycloak Details
First, we need to tell Symfony where our Keycloak realm is. Add these to your .env file. (Adjust the values for your Keycloak setup).
###> Keycloak Configuration ###
# The base URL of your realm
KEYCLOAK_REALM_URL=http://localhost:8081/realms/my-realm
# The "Client ID" of your application within that realm
KEYCLOAK_CLIENT_ID=my-symfony-client
###< Keycloak Configuration ###
The JWKS Provider (KeycloakJwkProvider)
This service is responsible for fetching, parsing, converting, and caching Keycloak’s public keys.
Create src/Security/KeycloakJwkProvider.php:
// src/Security/KeycloakJwkProvider.php
namespace App\Security;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Math\BigInteger;
use Psr\Log\LoggerInterface;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class KeycloakJwkProvider
{
private const CACHE_KEY = 'keycloak_jwks';
private readonly CacheInterface $cache;
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $keycloakRealmUrl,
private readonly LoggerInterface $logger
) {
// We use a simple FilesystemAdapter for caching the keys
$this->cache = new FilesystemAdapter();
}
/**
* Fetches the public keys (JWKS) from Keycloak, converts,
* caches, and returns them as a map of [kid => PEM_key].
*/
public function getPublicKeyMap(): array
{
try {
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
$this->logger->info('Keycloak JWKS cache miss. Fetching new keys.');
// Expire cache entry after 1 hour
$item->expiresAfter(3600);
// Fetch keys from Keycloak's certs endpoint
$jwksUrl = $this->keycloakRealmUrl . '/protocol/openid-connect/certs';
$response = $this->httpClient->request('GET', $jwksUrl);
$jwks = $response->toArray();
$keyMap = [];
if (!isset($jwks['keys'])) {
throw new \RuntimeException('Invalid JWKS format');
}
foreach ($jwks['keys'] as $key) {
if ($key['kty'] !== 'RSA' || !isset($key['kid'])) {
continue;
}
// Convert the JWK 'n' (modulus) and 'e' (exponent) to a PEM public key
$keyMap[$key['kid']] = $this->convertJwkToPem($key);
}
if (empty($keyMap)) {
throw new \RuntimeException('No valid RSA keys found in JWKS.');
}
return $keyMap;
});
} catch (\Exception $e) {
$this->logger->error('Failed to fetch or parse Keycloak JWKS: ' . $e->getMessage());
return []; // Return empty array on failure
}
}
/**
* Uses phpseclib to convert the n/e components of a JWK into a
* standard PEM-formatted public key.
*/
private function convertJwkToPem(array $jwk): string
{
$n = new BigInteger($this->base64UrlDecode($jwk['n']), 256);
$e = new BigInteger($this->base64UrlDecode($jwk['e']), 256);
$publicKey = PublicKeyLoader::load([
'n' => $n,
'e' => $e
]);
return $publicKey->toString('PKCS8');
}
private function base64UrlDecode(string $input): string
{
$remainder = strlen($input) % 4;
if ($remainder) {
$padlen = 4 - $remainder;
$input .= str_repeat('=', $padlen);
}
return base64_decode(strtr($input, '-_', '+/'));
}
}
Now, we have to configure this service in config/services.yaml to inject the .env var:
# config/services.yaml
services:
# ... other services
App\Security\KeycloakJwkProvider:
arguments:
$keycloakRealmUrl: '%env(KEYCLOAK_REALM_URL)%'
The JIT User Provisioner (UserManager)
This service is responsible for finding a local User that matches the Keycloak token, or creating one if it’s their first time connecting.
Create src/Security/UserManager.php:
// src/Security/UserManager.php
namespace App\Security;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
class UserManager
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger
) {
}
/**
* Finds a user by their Keycloak 'sub' (subject) ID.
* If not found, creates a new user with data from the token payload.
*/
public function findOrCreateFromKeycloakPayload(array $payload): User
{
$keycloakId = $payload['sub'] ?? null;
if (!$keycloakId) {
throw new \InvalidArgumentException('Keycloak payload must have a "sub" claim.');
}
$user = $this->userRepository->findOneBy(['keycloakId' => $keycloakId]);
if ($user) {
// Optional: You could update the user's email if it has
// changed in Keycloak, but be careful of email collisions.
return $user;
}
// User not found, provision a new one
$this->logger->info(sprintf(
'User with Keycloak ID %s not found. Provisioning new user.',
$keycloakId
));
$email = $payload['email'] ?? null;
if (!$email) {
throw new \InvalidArgumentException('Keycloak payload must have an "email" claim for new users.');
}
// Check if email is already in use by a *different* account
$existingUser = $this->userRepository->findOneBy(['email' => $email]);
if ($existingUser) {
$this->logger->error(sprintf(
'Cannot provision user. Email %s already exists for a different user.',
$email
));
throw new \RuntimeException('User email already exists');
}
$user = new User();
$user->setKeycloakId($keycloakId);
$user->setEmail($email);
$user->setRoles(['ROLE_USER']); // Set default roles
$this->em->persist($user);
$this->em->flush();
return $user;
}
}
This service will be autowired by Symfony automatically.
Implementing the WebSocket Server
Now let’s configure the GOS bundle.
Configure gos_web_socket.yaml
The composer require command should have created config/packages/gos_web_socket.yaml. We’ll set it up except for the session handler.
# config/packages/gos_web_socket.yaml
gos_web_socket:
server:
port: 8080 # The port the socket server will listen on
host: 127.0.0.1 # The host the socket server will listen on
router:
resources:
- '%kernel.project_dir%/config/websocket_routing.yaml'
client:
storage:
driver: gos_web_socket.client.storage.driver.in_memory
# We will set 'session_handler' in the next part.
pushers:
wamp:
default: true
Create our “Topic” (The Channel)
A “Topic” is a channel clients subscribe to. We will create a ChatTopic that is optimized to read data from our lean session, avoiding unnecessary database calls.
Create src/Websocket/ChatTopic.php:
// src/Websocket/ChatTopic.php
namespace App\Websocket;
use App\Repository\UserRepository;
use Gos\Bundle\WebSocketBundle\Router\WampRequest;
use Gos\Bundle\WebSocketBundle\Topic\TopicInterface;
use Psr\Log\LoggerInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Wamp\Topic;
use Symfony\Component\HttpFoundation\ParameterBag;
class ChatTopic implements TopicInterface
{
public function __construct(
private readonly LoggerInterface $logger,
// We can still inject the repo for occasional "fresh" data lookups if needed
private readonly UserRepository $userRepository
) {
}
/**
* Helper to get the full session data
*/
private function getSessionData(ConnectionInterface $connection): ParameterBag
{
// This is the ParameterBag we return from our JwtSessionProvider
return $connection->WAMP->getSession();
}
public function onSubscribe(ConnectionInterface $connection, Topic $topic, WampRequest $request): void
{
$session = $this->getSessionData($connection);
// Example Authorization: Check roles from the session
if ($topic->getId() === 'chat/admin' && !in_array('ROLE_ADMIN', $session->get('roles'))) {
$this->logger->warning(sprintf(
'User %s (ID: %s) denied subscription to admin topic',
$session->get('email'),
$session->get('user_id')
));
$connection->close();
return;
}
$this->logger->info(sprintf(
'User %s (ID: %s) subscribed to topic %s',
$session->get('email'),
$session->get('user_id'),
$topic->getId()
));
// Greet the new user
$connection->event($topic->getId(), [
'from' => 'System',
'message' => 'Welcome ' . $session->get('email')
]);
// Notify everyone else
$topic->broadcast([
'from' => 'System',
'message' => $session->get('email') . ' has joined the chat.'
], exclude: [$connection->resourceId]);
}
public function onUnSubscribe(ConnectionInterface $connection, Topic $topic, WampRequest $request): void
{
$session = $this->getSessionData($connection);
$this->logger->info(sprintf(
'User %s (ID: %s) unsubscribed from topic %s',
$session->get('email'),
$session->get('user_id'),
$topic->getId()
));
// Notify everyone
$topic->broadcast([
'from' => 'System',
'message' => $session->get('email') . ' has left the chat.'
]);
}
public function onPublish(
ConnectionInterface $connection,
Topic $topic,
WampRequest $request,
$event,
array $exclude,
array $eligible
): void {
$session = $this->getSessionData($connection);
$this->logger->info(sprintf(
'User %s (ID: %s) published to topic %s: %s',
$session->get('email'),
$session->get('user_id'),
$topic->getId(),
$event
));
// Broadcast the message with the authenticated user's email
$topic->broadcast([
'from' => $session->get('email'), // Data is right in the session!
'message' => $event
]);
}
public function getName(): string
{
return 'app.chat.topic';
}
}
Register the Topic
Create config/websocket_routing.yaml:
# config/websocket_routing.yaml
- name: 'chat/main' # The public-facing name clients subscribe to
service: App\Websocket\ChatTopic # The service ID of our Topic class
Our server is now ready, but it’s unsecured.
The Bridge — Authenticating WebSockets with Keycloak
This is the lynchpin. We will create our SessionProvider to bridge Keycloak’s auth and our WebSocket. It will validate the token and attach the lean identity data to the session.
Create KeycloakJwtSessionProvider.php
This service will validate the token using our KeycloakJwkProvider and provision the user with our UserManager.
Create src/Security/KeycloakJwtSessionProvider.php:
// src/Security/KeycloakJwtSessionProvider.php
namespace App\Security;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Gos\Bundle\WebSocketBundle\Session\SessionProviderInterface;
use Psr\Log\LoggerInterface;
use Ratchet\ConnectionInterface;
use Symfony\Component\HttpFoundation\ParameterBag;
class KeycloakJwtSessionProvider implements SessionProviderInterface
{
public function __construct(
private readonly KeycloakJwkProvider $jwkProvider,
private readonly UserManager $userManager,
private readonly LoggerInterface $logger,
private readonly string $keycloakRealmUrl,
private readonly string $keycloakClientId
) {
}
/**
* @throws \Exception
*/
public function getSession(ConnectionInterface $connection): ParameterBag
{
try {
$token = $this->getTokenFromConnection($connection);
if ($token === null) {
throw new \RuntimeException('No token passed in query parameters');
}
// 1. Get the map of public keys
$publicKeys = $this->jwkProvider->getPublicKeyMap();
if (empty($publicKeys)) {
throw new \RuntimeException('Could not load public keys from Keycloak.');
}
// 2. Decode the token
// firebase/php-jwt v6+ can accept an array of keys [kid => key]
$decodedToken = JWT::decode($token, $publicKeys);
$payload = (array) $decodedToken;
// 3. Manually validate 'iss' (issuer) and 'aud' (audience)
if ($payload['iss'] !== $this->keycloakRealmUrl) {
throw new \RuntimeException('Invalid token issuer (iss).');
}
if ($payload['aud'] !== $this->keycloakClientId) {
// Note: 'aud' can be an array, handle if necessary
throw new \RuntimeException('Invalid token audience (aud).');
}
// 4. All checks passed. Find or create the local user.
$user = $this->userManager->findOrCreateFromKeycloakPayload($payload);
// 5. Extract roles from the token
// Adjust 'realm_access' if you use client-level roles.
$keycloakRoles = $payload['realm_access']->roles ?? [];
$roles = array_merge(['ROLE_USER'], $keycloakRoles); // Ensure a base role
$roles = array_unique(preg_grep('/^ROLE_/', $roles)); // Ensure they fit Symfony's format
$this->logger->info(sprintf(
'User %s (ID: %s, Sub: %s) authenticated via Keycloak with roles [%s]',
$user->getEmail(),
$user->getId(),
$user->getKeycloakId(),
implode(', ', $roles)
));
// 6. Attach the lean identity data to the session
return new ParameterBag([
'user_id' => $user->getId(),
'keycloak_id' => $user->getKeycloakId(),
'roles' => $roles,
'email' => $user->getEmail(), // Good for logging/display
'activity_status' => 'online', // Default dynamic status
]);
} catch (ExpiredException $e) {
$this->logger->warning('WebSocket connection rejected: Token expired', ['e' => $e]);
$connection->close();
throw $e;
} catch (\Exception $e) {
$this->logger->warning(sprintf(
'WebSocket connection %s rejected: %s',
$connection->resourceId,
$e->getMessage()
));
$connection->close();
throw $e;
}
}
private function getTokenFromConnection(ConnectionInterface $connection): ?string
{
$queryString = $connection->httpRequest->getUri()->getQuery();
if (empty($queryString)) {
return null;
}
parse_str($queryString, $queryParams);
return $queryParams['token'] ?? null;
}
}
Configure the Services
Finally, we inject our .env vars into the new provider and tell the GOS bundle to use it.
First, config/services.yaml:
# config/services.yaml
services:
# ...
App\Security\KeycloakJwkProvider:
arguments:
$keycloakRealmUrl: '%env(KEYCLOAK_REALM_URL)%'
# 👇 NEW SERVICE CONFIG
App\Security\KeycloakJwtSessionProvider:
arguments:
$keycloakRealmUrl: '%env(KEYCLOAK_REALM_URL)%'
$keycloakClientId: '%env(KEYCLOAK_CLIENT_ID)%'
Next, config/packages/gos_web_socket.yaml:
# config/packages/gos_web_socket.yaml
gos_web_socket:
# ... server, client, pushers config ...
# 👇 THIS IS THE KEY
# Tell GOS to use our new service for session handling
session_handler: App\Security\KeycloakJwtSessionProvider
Bonus — Handling Dynamic State (Activity Status)
activity_status is not identity; it’s dynamic presence. We should not get it from the token. We’ll create a Remote Procedure Call (RPC) to allow the client to update its own status.
Create the RPC Service
Create a new file src/Websocket/ActivityRpc.php:
// src/Websocket/ActivityRpc.php
namespace App\Websocket;
use Gos\Bundle\WebSocketBundle\RPC\RpcInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Wamp\Topic;
use Gos\Bundle\WebSocketBundle\Router\WampRequest;
use Psr\Log\LoggerInterface;
class ActivityRpc implements RpcInterface
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly Topic $chatTopic // We'll autowire the "chat/main" topic
) {
}
/**
* The client will call this method.
*
* @param string $status The new status (e.g., "away", "busy", "online")
* @return array
*/
public function setStatus(ConnectionInterface $connection, WampRequest $request, $status): array
{
$session = $connection->WAMP->getSession();
$validStatuses = ['online', 'away', 'busy'];
if (!in_array($status, $validStatuses)) {
return ['status' => 'error', 'message' => 'Invalid status'];
}
// 1. Update the status in this connection's session
$session->set('activity_status', $status);
$this->logger->info(sprintf(
'User %s (ID: %s) set status to %s',
$session->get('email'),
$session->get('user_id'),
$status
));
// 2. Broadcast this change to everyone else
$this->chatTopic->broadcast([
'from' => 'System',
'message' => sprintf(
'%s is now %s',
$session->get('email'),
$status
)
]);
return ['status' => 'ok', 'new_status' => $status];
}
/**
* The name of the RPC service.
*/
public function getName(): string
{
return 'app.activity.rpc';
}
}
Configure the RPC Service
First, we need to tell Symfony how to inject the chatTopic. In config/services.yaml:
# config/services.yaml
services:
# ... other services
# Find the service for the "chat/main" topic
App\Websocket\ChatTopic:
tags: ['gos_web_socket.topic']
App\Websocket\ActivityRpc:
arguments:
# Inject the chat topic service by its ID
$chatTopic: '@App\Websocket\ChatTopic'
Next, add this RPC to your config/websocket_routing.yaml:
# config/websocket_routing.yaml
- name: 'chat/main'
service: App\Websocket\ChatTopic
# 👇 NEW RPC ROUTE
- name: 'rpc/activity' # The public-facing name clients call
service: App\Websocket\ActivityRpc # The service ID of our RPC class
Building the Frontend Client
Our client no longer “logs in” via our Symfony app. It assumes it already has a token from Keycloak.
Create public/chat.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Symfony WebSocket Chat (Keycloak)</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; display: grid; grid-template-rows: auto auto 1fr auto; height: 100vh; }
.header { background: #333; color: white; padding: 10px; }
#connect-form { padding: 10px; background: #f4f4f4; border-bottom: 1px solid #ddd; }
#connect-form textarea { width: 98%; min-height: 60px; }
#chat-window { overflow-y: auto; padding: 10px; }
#chat-window .system { font-style: italic; color: #777; }
#chat-window .message { padding: 5px 10px; background: #e0f7fa; border-radius: 10px; display: inline-block; }
#chat-window .message strong { color: #00796b; }
#message-form { border-top: 1px solid #ddd; padding: 10px; display: flex; }
#message-input { flex-grow: 1; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
#message-form button { padding: 8px 12px; margin-left: 5px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
#status { font-weight: bold; }
#status.connected { color: green; }
#status.disconnected { color: red; }
</style>
</head>
<body>
<div class="header">
<h2>Symfony 7.3 WebSocket Chat (Keycloak Auth)</h2>
<div>Status: <span id="status" class="disconnected">Disconnected</span></div>
</div>
<div id="connect-form">
<h3>1. Paste Keycloak Access Token</h3>
<form id="connectForm">
<textarea id="token-input" placeholder="Paste your Keycloak Access Token here..."></textarea>
<button type="submit">Connect</button>
<button type="button" id="statusAway" style="display: none;">Go Away</button>
</form>
</div>
<div id="chat-window">
<div class="system">Please paste a valid Keycloak token and connect...</div>
</div>
<div id="message-form" style="display: none;">
<input type="text" id="message-input" placeholder="Type a message..." autocomplete="off">
<button type="submit" id="sendButton">Send</button>
</div>
<script>
const connectForm = document.getElementById('connectForm');
const tokenInput = document.getElementById('token-input');
const chatWindow = document.getElementById('chat-window');
const messageForm = document.getElementById('message-form');
const messageInput = document.getElementById('message-input');
const statusEl = document.getElementById('status');
const statusAwayBtn = document.getElementById('statusAway');
let jwtToken = null;
let socket = null;
const WEBSOCKET_HOST = 'localhost:8080';
const CHAT_TOPIC = 'chat/main';
const RPC_ACTIVITY = 'rpc/activity';
// 1. Handle Connection
connectForm.addEventListener('submit', (e) => {
e.preventDefault();
jwtToken = tokenInput.value.trim();
if (!jwtToken) {
addSystemMessage('Please paste a token.');
return;
}
addSystemMessage('Token received. Connecting to WebSocket...');
connectWebSocket();
});
// 2. Connect to WebSocket
function connectWebSocket() {
if (!jwtToken) return;
if (socket) socket.close();
socket = new WebSocket(`ws://${WEBSOCKET_HOST}?token=${jwtToken}`);
socket.onopen = () => {
statusEl.textContent = 'Connected';
statusEl.className = 'connected';
messageForm.style.display = 'flex';
statusAwayBtn.style.display = 'inline-block';
addSystemMessage('WebSocket connection established. Subscribing to chat...');
// WAMP Protocol: [5, "topic_name"] (SUBSCRIBE)
socket.send(JSON.stringify([5, CHAT_TOPIC]));
};
socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
const [type] = msg;
// [8, "topic_name", {payload}] (EVENT)
if (type === 8) {
const [_, topic, data] = msg;
if (topic === CHAT_TOPIC) {
addChatMessage(data.from, data.message);
}
}
// [3, "call_id", {payload}] (CALL_RESULT)
else if (type === 3) {
console.log('RPC Call Result:', msg[2]);
}
};
socket.onclose = () => {
statusEl.textContent = 'Disconnected';
statusEl.className = 'disconnected';
messageForm.style.display = 'none';
statusAwayBtn.style.display = 'none';
addSystemMessage('WebSocket connection closed.');
socket = null;
};
}
// 3. Handle Sending Messages
messageForm.addEventListener('submit', (e) => {
e.preventDefault();
const message = messageInput.value;
if (!message || !socket || socket.readyState !== WebSocket.OPEN) return;
// WAMP Protocol: [7, "topic_name", "message_payload"] (PUBLISH)
socket.send(JSON.stringify([7, CHAT_TOPIC, message]));
messageInput.value = '';
});
// 4. Handle RPC Call
statusAwayBtn.addEventListener('click', () => {
if (!socket || socket.readyState !== WebSocket.OPEN) return;
// WAMP Protocol: [2, "call_id", "rpc_name", ...args]
const callId = 'activity_call_' + Date.now();
socket.send(JSON.stringify([2, callId, RPC_ACTIVITY, 'away']));
});
// --- UI Helper Functions ---
function addSystemMessage(message) {
chatWindow.innerHTML += `<div class="system">${message}</div>`;
chatWindow.scrollTop = chatWindow.scrollHeight;
}
function addChatMessage(from, message) {
chatWindow.innerHTML += `<div class="message"><strong>${from}:</strong> <span>${message}</span></div>`;
chatWindow.scrollTop = chatWindow.scrollHeight;
}
</script>
</body>
</html>
Running & Verifying the Full Stack
This now involves three services: Keycloak, our Symfony server, and our WebSocket server.
Step 1: Run Keycloak
You must have a running Keycloak instance. A simple Docker command is:
docker run -p 8081:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:latest \
start-dev
- Go to http://localhost:8081 and log in.
- Create a new realm (e.g., my-realm).
- Create a new client (e.g., my-symfony-client).
- Set Access Type to public.
- Set Valid Redirect URIs to * for testing.
- Create a user (e.g., user1@test.com) and set their password.
Ensure your .env vars (KEYCLOAK_REALM_URL, KEYCLOAK_CLIENT_ID) match these values.
Step 2: Get a Keycloak Token
We need a valid Access Token. The simplest way for testing is to use the Password Grant type with cURL.
# In your terminal, request a token from Keycloak
# Replace realm, client_id, username, and password
curl -X POST "http://localhost:8081/realms/my-realm/protocol/openid-connect/token" \
-d "client_id=my-symfony-client" \
-d "grant_type=password" \
-d "username=user1@test.com" \
-d "password=your_user_pass"
This will return a JSON object. Copy the entire access_token string.
Step 3: Run the Symfony Servers
Run the Symfony Web Server (for chat.html):
# In Terminal 1
symfony server:start -d
Run the WebSocket Server:
# In Terminal 2
php bin/console gos:websocket:server
You should see [OK] Starting server on 127.0.0.1:8080.
Step 4: Verification
- Open a browser and go to http://127.0.0.1:8000/chat.html.
- Paste your access_token from Step 2 into the text area and click “Connect”.
- Observe your WebSocket Server Terminal (Terminal 2):
- The first time, you should see: [info] Keycloak JWKS cache miss. Fetching new keys.
- Then: [info] User with Keycloak ID … not found. Provisioning new user.
- Finally: [info] User user1@test.com (ID: 1, Sub: …) authenticated via Keycloak…
- Observe your Browser:
- The status will change to “Connected”.
- You’ll see: “Welcome user1@test.com”.
Get a token for a different user (user2@test.com), open a second browser tab, and connect with that token.
Observe: The first tab will show “user2@test.com has joined the chat.”
Send messages back and forth. They will be correctly attributed.
In one tab, click the “Go Away” button. Observe the other tab. It will receive the system message: “user1@test.com is now away”.
Conclusion
We have successfully built a truly robust, enterprise-grade, real-time application.
We’ve decoupled our authentication, delegating it to Keycloak. We’ve avoided the performance-killing introspection anti-pattern by building a cached, high-speed local JWKS validator. We’ve used JIT provisioning to create local user representations. And finally, we’ve separated identity (from the token) from dynamic state (like activity status) by using a combination of a lean session and RPCs.
This architecture is fast, secure, and ready to scale. You now have the complete blueprint for handling any real-time, SSO-authenticated feature your business can dream up.
Top comments (0)