By using this site, you agree to the Privacy Policy and Terms of Use.
Accept
World of SoftwareWorld of SoftwareWorld of Software
  • News
  • Software
  • Mobile
  • Computing
  • Gaming
  • Videos
  • More
    • Gadget
    • Web Stories
    • Trending
    • Press Release
Search
  • Privacy
  • Terms
  • Advertise
  • Contact
Copyright © All Rights Reserved. World of Software.
Reading: How to Solve Real-Time Auth Without Having to Sacrifice Performance | HackerNoon
Share
Sign In
Notification Show More
Font ResizerAa
World of SoftwareWorld of Software
Font ResizerAa
  • Software
  • Mobile
  • Computing
  • Gadget
  • Gaming
  • Videos
Search
  • News
  • Software
  • Mobile
  • Computing
  • Gaming
  • Videos
  • More
    • Gadget
    • Web Stories
    • Trending
    • Press Release
Have an existing account? Sign In
Follow US
  • Privacy
  • Terms
  • Advertise
  • Contact
Copyright © All Rights Reserved. World of Software.
World of Software > Computing > How to Solve Real-Time Auth Without Having to Sacrifice Performance | HackerNoon
Computing

How to Solve Real-Time Auth Without Having to Sacrifice Performance | HackerNoon

News Room
Last updated: 2025/11/16 at 6:23 PM
News Room Published 16 November 2025
Share
How to Solve Real-Time Auth Without Having to Sacrifice Performance | HackerNoon
SHARE

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).

  1. Client (Browser): The user is redirected to Keycloak, logs in, and is redirected back to our (hypothetical) main application with an Access Token (JWT).
  2. 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.
  3. Server (GOS Bundle): The WebSocket server receives the connection request.
  4. Server (Our Custom Code): It triggers our new KeycloakJwtSessionProvider service.
  5. 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.

6. 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.

7. 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.

8. 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 (userid, keycloakid, roles, email) to the WebSocket connection’s session.

9. 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:

  1. Remove the PasswordAuthenticatedUserInterface and all password-related properties/methods.
  2. 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 AppEntity;

use AppRepositoryUserRepository;
use DoctrineORMMapping as ORM;
use SymfonyBridgeDoctrineValidatorConstraintsUniqueEntity;
use SymfonyComponentSecurityCoreUserUserInterface; // We only need this one

#[ORMEntity(repositoryClass: UserRepository::class)]
#[ORMTable(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
{
    #[ORMId]
    #[ORMGeneratedValue]
    #[ORMColumn]
    private ?int $id = null;

    #[ORMColumn(length: 180, unique: true)]
    private ?string $email = null;

    // 👇 NEW FIELD
    #[ORMColumn(length: 255, unique: true, nullable: true)]
    private ?string $keycloakId = null;

    /**
     * @var list<string> The user roles
     */
    #[ORMColumn]
    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:migrationphp 
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 AppSecurity;

use phpseclib3CryptPublicKeyLoader;
use phpseclib3MathBigInteger;
use PsrLogLoggerInterface;
use SymfonyComponentCacheAdapterFilesystemAdapter;
use SymfonyContractsCacheCacheInterface;
use SymfonyContractsCacheItemInterface;
use SymfonyContractsHttpClientHttpClientInterface;

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

    AppSecurityKeycloakJwkProvider:
        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 AppSecurity;

use AppEntityUser;
use AppRepositoryUserRepository;
use DoctrineORMEntityManagerInterface;
use PsrLogLoggerInterface;

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 goswebsocket.yaml

The composer requires the command to have created config/packages/goswebsocket.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 AppWebsocket;

use AppRepositoryUserRepository;
use GosBundleWebSocketBundleRouterWampRequest;
use GosBundleWebSocketBundleTopicTopicInterface;
use PsrLogLoggerInterface;
use RatchetConnectionInterface;
use RatchetWampTopic;
use SymfonyComponentHttpFoundationParameterBag;

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: AppWebsocketChatTopic # 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 AppSecurity;

use FirebaseJWTExpiredException;
use FirebaseJWTJWT;
use FirebaseJWTKey;
use GosBundleWebSocketBundleSessionSessionProviderInterface;
use PsrLogLoggerInterface;
use RatchetConnectionInterface;
use SymfonyComponentHttpFoundationParameterBag;

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 them.

First, config/services.yaml:

# config/services.yaml
services:
    # ...
    AppSecurityKeycloakJwkProvider:
        arguments:
            $keycloakRealmUrl: '%env(KEYCLOAK_REALM_URL)%'

    # 👇 NEW SERVICE CONFIG
    AppSecurityKeycloakJwtSessionProvider:
        arguments:
            $keycloakRealmUrl: '%env(KEYCLOAK_REALM_URL)%'
            $keycloakClientId: '%env(KEYCLOAK_CLIENT_ID)%'

Next, config/packages/goswebsocket.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: AppSecurityKeycloakJwtSessionProvider

Bonus — Handling Dynamic State (Activity Status)

activity_status is not identity; it’s a 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 AppWebsocket;

use GosBundleWebSocketBundleRPCRpcInterface;
use RatchetConnectionInterface;
use RatchetWampTopic;
use GosBundleWebSocketBundleRouterWampRequest;
use PsrLogLoggerInterface;

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
    AppWebsocketChatTopic:
        tags: ['gos_web_socket.topic']

    AppWebsocketActivityRpc:
        arguments:
            # Inject the chat topic service by its ID
            $chatTopic: '@AppWebsocketChatTopic'

Next, add this RPC to your config/websocket_routing.yaml:

# config/websocket_routing.yaml
- name: 'chat/main'
  service: AppWebsocketChatTopic

# 👇 NEW RPC ROUTE
- name: 'rpc/activity'             # The public-facing name clients call
  service: AppWebsocketActivityRpc   # 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., [email protected]) and set their password.

Ensure your .env vars (KEYCLOAKREALMURL, KEYCLOAKCLIENTID) 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 "[email protected]" 
     -d "password=your_user_pass"

This will return a JSON object. Copy the entire access_token string.

Step 3: Run the Symfony ServersRun 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

  1. Open a browser and go to http://127.0.0.1:8000/chat.html.
  2. Paste your access_token from Step 2 into the text area and click “Connect.”
  3. 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 a new user.
  • Finally: [info] User [email protected] (ID: 1, Sub: …) authenticated via Keycloak…

4. Observe your Browser:

  • The status will change to “Connected.”
  • You’ll see: “Welcome [email protected].”

5. Get a token for a different user ([email protected]), open a second browser tab, and connect with that token.

6. Observe: The first tab will show “[email protected] has joined the chat.”

7. Send messages back and forth. They will be correctly attributed.

8. In one tab, click the “Go Away” button. Observe the other tab. It will receive the system message: “[email protected] 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.

Sign Up For Daily Newsletter

Be keep up! Get the latest breaking news delivered straight to your inbox.
By signing up, you agree to our Terms of Use and acknowledge the data practices in our Privacy Policy. You may unsubscribe at any time.
Share This Article
Facebook Twitter Email Print
Share
What do you think?
Love0
Sad0
Happy0
Sleepy0
Angry0
Dead0
Wink0
Previous Article T-Mobile is seemingly forcing T-Life on its customers, and the majority of you hate it T-Mobile is seemingly forcing T-Life on its customers, and the majority of you hate it
Next Article 5 Everyday NASA Inventions And Electronics In Your Home – BGR 5 Everyday NASA Inventions And Electronics In Your Home – BGR
Leave a comment

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Stay Connected

248.1k Like
69.1k Follow
134k Pin
54.3k Follow

Latest News

Tencent profit grows 54% y-o-y as short video service boosts ad revenue · TechNode
Tencent profit grows 54% y-o-y as short video service boosts ad revenue · TechNode
Computing
The YouTube TV-Disney dispute is over: Here’s what you’re getting
The YouTube TV-Disney dispute is over: Here’s what you’re getting
News
Apple Fitness+ is ‘Under Review’ — Here’s What Might Happen Next
Apple Fitness+ is ‘Under Review’ — Here’s What Might Happen Next
News
Tencent’s Q1 gaming revenue shows signs of recovery · TechNode
Tencent’s Q1 gaming revenue shows signs of recovery · TechNode
Computing

You Might also Like

Tencent profit grows 54% y-o-y as short video service boosts ad revenue · TechNode
Computing

Tencent profit grows 54% y-o-y as short video service boosts ad revenue · TechNode

1 Min Read
Tencent’s Q1 gaming revenue shows signs of recovery · TechNode
Computing

Tencent’s Q1 gaming revenue shows signs of recovery · TechNode

4 Min Read
Chinese firms CXMT and Wuhan Xinxin make progress in high bandwidth memory production for AI chips · TechNode
Computing

Chinese firms CXMT and Wuhan Xinxin make progress in high bandwidth memory production for AI chips · TechNode

1 Min Read
ByteDance surprises AI rivals with ultra-low cost Doubao model · TechNode
Computing

ByteDance surprises AI rivals with ultra-low cost Doubao model · TechNode

2 Min Read
//

World of Software is your one-stop website for the latest tech news and updates, follow us now to get the news that matters to you.

Quick Link

  • Privacy Policy
  • Terms of use
  • Advertise
  • Contact

Topics

  • Computing
  • Software
  • Press Release
  • Trending

Sign Up for Our Newsletter

Subscribe to our newsletter to get our newest articles instantly!

World of SoftwareWorld of Software
Follow US
Copyright © All Rights Reserved. World of Software.
Welcome Back!

Sign in to your account

Lost your password?