11. 09. 2025 Andrea Mariani NetEye, PHP, Unified Monitoring

Using Keycloak to Secure Web Pages and Virtual Directories

While working on some internal tools, I needed secure access to a few PHP pages and virtual directories resources that, by default, didn’t have any built-in access control. Since NetEye already uses Keycloak as its authentication system, I decided to leverage it to handle login and user validation.

This way I could avoid reinventing the wheel and rely on a centralized, robust identity provider that was already part of the infrastructure.

Here’s how I implemented two different setups:

  • Protecting an entire virtual directory like using Apache and mod_auth_openidc
  • Securing a single PHP page using a manual OpenID Connect flow

Retrieving the Client Secret from Keycloak

Before diving into the two methods, you’ll need the client secret for the client “neteye” in Keycloak. This is required whether you’re configuring Apache or writing custom PHP code.
In the screenshots below, I’ll show you exactly where to find it.

First, navigate to the Keycloak admin console:

Select the realm used by NetEye (usually “Neteye master”), go to Clients and select “neteye”:

Under the Credentials tab, you’ll find the client secret:

Make sure to copy it securely, you’ll need it in both configuration scenarios below.

Option 1: Apache + mod_auth_openidc for Virtual Directories

This method is ideal if your application is served by Apache and you want to protect a full path without modifying the app itself.

What You Need

  • Keycloak already configured (via NetEye)
  • The mod_auth_openidc module installed:
dnf install mod_auth_openidc

Apache Configuration

Here’s the configuration I used to secure “/test”:

cd /etc/httpd/conf.d
vim test.conf

Edit the file as shown below, and make sure to replace:

  • “/test” with the actual name of your virtual directory
  • “<YOUR_FQDN>” with your server’s fully qualified domain name
  • “<YOUR_CLIENT_SECRET>” with the client secret you retrieved earlier from Keycloak
LoadModule auth_openidc_module modules/mod_auth_openidc.so

OIDCProviderMetadataURL https://<YOUR_FQDN>/auth/realms/master/.well-known/openid-configuration
OIDCClientID neteye
OIDCClientSecret <YOUR_CLIENT_SECRET>
OIDCRedirectURI https://<YOUR_FQDN>/test/redirect_uri
OIDCCryptoPassphrase a1b2c3d4e5f6g7h8i9j0strongpassphrase
OIDCSSLValidateServer Off
OIDCStateTimeout 600

<Location /test>
    AuthType openid-connect
    Require valid-user
</Location>

ProxyPass        /test https://<YOUR_FQDN>:<YOUR_PORT>/test/
ProxyPassReverse /test https://<YOUR_FQDN>:<YOUR_PORT>/test/

SSLProxyEngine       On
SSLProxyVerify       none
SSLProxyCheckPeerCN  off
SSLProxyCheckPeerName off

Keycloak Client Setup

In the Keycloak admin console (already configured by NetEye):

  • Go to the “neteye" client
  • Add this redirect URI:
    /test/*

Once this is in place, any access to /test will trigger Keycloak authentication. No need to modify the application itself.

Option 2: Manual OIDC Flow in PHP

For standalone PHP pages that don’t have any built-in access control, I created a custom authentication flow using Keycloak’s endpoints.

auth.php – Handles Authentication

This script manages the full OpenID Connect flow:

  • Redirects the user to Keycloak
  • Handles the callback with the authorization code
  • Exchanges the code for an access token
  • Stores the token in the session

You can include this at the top of any PHP page to enforce login:

<?php
session_start();
set_time_limit(300);

$kcBase        = 'https://<YOUR_FQDN>/auth/realms/master';
$authEndpoint  = $kcBase . '/protocol/openid-connect/auth';
$tokenEndpoint = $kcBase . '/protocol/openid-connect/token';

$clientId      = 'neteye';
$clientSecret  = '<YOUR_CLIENT_SECRET>';

$scope         = 'openid profile email';

$scriptPath    = $_SERVER['PHP_SELF'];
$redirectUri   = 'https://' . $_SERVER['HTTP_HOST'] . $scriptPath
               . (empty($_SERVER['QUERY_STRING']) ? '' : '?' . $_SERVER['QUERY_STRING']);

if (empty($_SESSION['access_token'])) {
    if (isset($_GET['code'])) {
        if (empty($_GET['state'])
            || ! isset($_SESSION['oauth2state'])
            || $_GET['state'] !== $_SESSION['oauth2state']
        ) {
            unset($_SESSION['oauth2state']);
            die('Invalid state');
        }

        $post = [
            'grant_type'    => 'authorization_code',
            'code'          => $_GET['code'],
            'redirect_uri'  => $redirectUri,
        ];
        $ch = curl_init($tokenEndpoint);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => http_build_query($post),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADER         => true,
            CURLOPT_HTTPHEADER     => [
                'Accept: application/json',
                'Authorization: Basic ' . base64_encode("$clientId:$clientSecret")
            ],
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_SSL_VERIFYHOST => 0,
        ]);
        $resp = curl_exec($ch);
        if ($resp === false) {
            die('CURL error: ' . curl_error($ch));
        }
        $httpCode   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $hdrSize    = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
        $header     = substr($resp, 0, $hdrSize);
        $body       = substr($resp, $hdrSize);
        curl_close($ch);

        if ($httpCode !== 200) {
            header('Content-Type: text/plain; charset=utf-8');
            echo "HTTP_CODE: $httpCode\n=== HEADER ===\n$header\n=== BODY ===\n$body";
            exit;
        }

        $data = json_decode($body, true);
        if (empty($data['access_token'])) {
            die('Nessun access_token ricevuto');
        }
        $_SESSION['access_token'] = $data['access_token'];

        // rimuovo code e state dalla querystring
        header('Location: ' . $redirectUri);
        exit;
    }

    $state = bin2hex(random_bytes(16));
    $_SESSION['oauth2state'] = $state;
    $params = [
        'response_type' => 'code',
        'client_id'     => $clientId,
        'redirect_uri'  => $redirectUri,
        'scope'         => $scope,
        'state'         => $state,
    ];
    header('Location: ' . $authEndpoint . '?' . http_build_query($params));
    exit;
}

protected_page.php – A Generic Protected Page

Here’s a simple example of a PHP page that doesn’t execute any commands or require parameters. It just displays hard-coded, static content to authenticated users.

<?php

require __DIR__ . '/auth.php';

?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Protected Page</title>
    <style>
        body {
            font-family: sans-serif;
            margin: 2em;
        }
        .container {
            max-width: 600px;
            margin: auto;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Welcome to the Protected Page</h1>
        <p>You are successfully authenticated via Keycloak.</p>
        <p>This page is only accessible to users with valid credentials.</p>
    </div>
</body>
</html>

Conclusions

Integrating Keycloak into my PHP pages and virtual directories turned out to be a clean and effective way to enforce authentication – especially since NetEye already uses it as its identity provider. Instead of building a custom login system or relying on basic .htaccess rules, I was able to hook into a centralized, secure, and scalable solution.

Whether you’re protecting an entire Apache-served directory or a single PHP page, the two approaches I described are flexible enough to cover most use cases:

  • The Apache method is quick to set up and ideal for static or legacy apps
  • The PHP method gives you full control and works well for custom workflows

These Solutions are Engineered by Humans

Did you find this article interesting? Does it match your skill set? Programming is at the heart of how we develop customized solutions. In fact, we’re currently hiring for roles just like this and others here at Würth Phoenix.

Andrea Mariani

Andrea Mariani

Author

Andrea Mariani

Leave a Reply

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

Archive