symfony-opcua · v4.3.x
Docs · Security

Certificates

Generating, signing, rotating the client (application) certificate. The mTLS-style identity the OPC UA server uses to recognise your Symfony app.

The client (application) certificate identifies your Symfony app to the OPC UA server. Every connection beyond security_mode: None requires one.

It's an X.509 cert with a private key — the same format as TLS server certs. Conventions differ:

  • The subject CN is your application name, not a hostname.
  • The cert needs an ApplicationUri extension matching the URI the server expects.
  • The server-side trust list is per-application, not per-CA.

Generating a cert

The simplest case — a self-signed cert:

bash self-signed RSA
mkdir -p /etc/opcua

openssl req -x509 -newkey rsa:2048 -keyout /etc/opcua/client.key \
    -out /etc/opcua/client.pem -days 365 -nodes \
    -subj "/CN=My Symfony Client/O=Acme" \
    -addext "subjectAltName=URI:urn:my-symfony:client,DNS:symfony.acme.local" \
    -addext "keyUsage=digitalSignature,keyEncipherment,dataEncipherment" \
    -addext "extendedKeyUsage=serverAuth,clientAuth"

Critical pieces:

  • URI:urn:my-symfony:client — the OPC UA ApplicationUri.
  • extendedKeyUsage=serverAuth,clientAuth — both required by the OPC UA spec.
  • keyUsage — sign, encipher keys, encipher data.

For ECC:

bash ECC cert
openssl ecparam -name prime256v1 -genkey -noout -out /etc/opcua/client.key
openssl req -x509 -key /etc/opcua/client.key \
    -out /etc/opcua/client.pem -days 365 \
    -subj "/CN=My Symfony Client/O=Acme" \
    -addext "subjectAltName=URI:urn:my-symfony:client"

Wiring up

bash .env
OPCUA_CLIENT_CERT=/etc/opcua/client.pem
OPCUA_CLIENT_KEY=/etc/opcua/client.key
text bundle config
php_opcua_symfony_opcua:
    connections:
        default:
            client_certificate: '%env(OPCUA_CLIENT_CERT)%'
            client_key:         '%env(OPCUA_CLIENT_KEY)%'

Trusting the cert server-side

The cert needs to land in the server's trusted client cert directory:

1 — First-connection prompt

Many servers default to "reject + move to rejected dir". An operator manually moves it to the trusted dir.

2 — Pre-stage

Drop the cert into the server's trusted dir before first connection — avoids the "first call fails" UX.

Per-server-product paths:

Server Trusted cert dir
open62541 (default) pki/trusted/certs/
Prosys OPC UA Simulation ~/.prosysopc/.../USER_PKI/CA/certs/
Siemens S7 PLCs TIA Portal config
KEPServerEX KEPServer Configuration UI

Cert rotation

Certs expire. Quarterly/annual rotation is standard.

Zero-downtime

  1. Generate new cert with same ApplicationUri.
  2. Stage in the server alongside the old. Both are now trusted.
  3. Update Symfony.env → new paths, deploy.
  4. Restart daemonsystemctl restart opcua-session-manager.
  5. Confirm connections under the new cert.
  6. Remove old cert from server.

With downtime (simpler)

  1. Generate new cert.
  2. Update server (remove old, add new).
  3. Update Symfony.
  4. Restart daemon.

Step 2 → step 4 is the downtime window — typically 10-30 s.

Cert chain — CA-signed

For larger fleets:

bash CA-signed
# 1. CA (once)
openssl req -x509 -newkey rsa:4096 -keyout opcua-ca.key \
    -out opcua-ca.pem -days 3650 -nodes \
    -subj "/CN=Acme OPC UA Root CA"

# 2. Client CSR
openssl req -new -newkey rsa:2048 -keyout client.key \
    -out client.csr -nodes \
    -subj "/CN=My Symfony Client/O=Acme"

# 3. Sign
openssl x509 -req -in client.csr -CA opcua-ca.pem -CAkey opcua-ca.key \
    -CAcreateserial -out client.pem -days 365 \
    -extfile <(echo "subjectAltName=URI:urn:my-symfony:client") \
    -extfile <(echo "extendedKeyUsage=serverAuth,clientAuth")

Trust the CA on the server; any cert it signs gets accepted. Wire the CA cert too:

bash .env
OPCUA_CA_CERT=/etc/opcua/opcua-ca.pem
text bundle config
ca_certificate: '%env(OPCUA_CA_CERT)%'

Permissions

bash terminal
sudo chown www-data:www-data /etc/opcua/client.{pem,key}
sudo chmod 0640 /etc/opcua/client.pem
sudo chmod 0600 /etc/opcua/client.key      # private — owner only

If FPM and the daemon run under different users, use a shared group instead of broadening permissions.

Per-connection certs

Large fleets: one cert per connection name for audit:

text per-connection
php_opcua_symfony_opcua:
    connections:
        plc-line-a:
            client_certificate: '/etc/opcua/line-a.pem'
            client_key:         '/etc/opcua/line-a.key'
        plc-line-b:
            client_certificate: '/etc/opcua/line-b.pem'
            client_key:         '/etc/opcua/line-b.key'

Server-side audit logs distinguish each line.

Cert expiry monitoring

A scheduled Symfony command:

php src/Command/CheckCertExpiryCommand.php
namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\{InputInterface, InputOption};
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;

#[AsCommand(name: 'app:opcua:cert:check')]
final class CheckCertExpiryCommand extends Command
{
    public function __construct(
        private array $opcuaConfig,
        private NotifierInterface $notifier,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addOption('days', null, InputOption::VALUE_REQUIRED, '', '30');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $threshold = (int) $input->getOption('days');
        $rc = 0;

        foreach ($this->opcuaConfig['connections'] as $name => $conn) {
            $certPath = $conn['client_certificate'] ?? null;
            if ($certPath === null) continue;

            $expiry = $this->certExpiry($certPath);
            $daysLeft = (new \DateTimeImmutable())->diff($expiry)->days;

            if ($daysLeft < $threshold) {
                $output->writeln("<warning>$name expires in $daysLeft days</warning>");
                $this->notifier->send(
                    new \App\Notification\CertExpiringSoon($name, $expiry),
                    new Recipient('[email protected]'),
                );
                $rc = 1;
            }
        }

        return $rc === 0 ? Command::SUCCESS : Command::FAILURE;
    }

    private function certExpiry(string $path): \DateTimeImmutable
    {
        $cert = openssl_x509_parse(file_get_contents($path));
        return new \DateTimeImmutable('@' . $cert['validTo_time_t']);
    }
}

Schedule daily via Symfony Scheduler — see Console and scheduler.

Inspecting a cert

bash inspect
openssl x509 -in /etc/opcua/client.pem -noout -text
openssl x509 -in /etc/opcua/client.pem -noout -dates
openssl x509 -in /etc/opcua/client.pem -noout -subject
openssl x509 -in /etc/opcua/client.pem -noout -ext subjectAltName

The SAN should contain URI:urn:... for OPC UA.

What goes wrong

Symptom Cause
UntrustedCertificateException Server doesn't have this cert in its trust list
Bad_CertificateInvalid Structure problem (missing extensions, bad URI)
Bad_CertificateUriInvalid ApplicationUri doesn't match SAN URI
Bad_CertificateUseNotAllowed Missing keyUsage or extendedKeyUsage
Bad_CertificateTimeInvalid Cert expired or not-yet-valid
Bad_SecurityChecksFailed Signature failure — wrong key