Tutorials

Native PHP: Secure Deployments with Automated Website Security Audits

Native PHP: Secure Deployments with Automated Website Security Audits

A deployment is not finished when the files reach production. It is finished when the public site is responding with the security posture you intended to ship. A proxy change, an expired certificate, or a missing browser header can turn an otherwise successful release into a quiet regression.

This tutorial builds a Native PHP 8.3 deployment check around the Website Security Analyzer. After each production release becomes reachable, a command submits its public HTTPS URL, maps the result into a strict domain object, and fails with a distinct exit code when high-severity findings appear.

The analyzer performs bounded, non-invasive inspection of public HTTPS and browser security posture. It is an operational signal, not a penetration test, vulnerability exploit, authenticated application scan, or substitute for code review.

Get access and create a service token

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Website Security Analyzer service page.
  3. Choose an available Free, Plus, or Pro plan and complete its activation.
  4. Open the official service documentation.
  5. Find the Service token panel and copy the service-scoped token displayed there.

This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. The implementation below uses a Bearer token so the credential does not appear in the URL, proxy access logs, or browser history.

Regenerating the service token revokes the previously active token. Treat rotation as an atomic deployment change: install the new value everywhere that runs the audit, verify it, and only then retire assumptions about the old credential.

Confirm the exact API call

The integration makes an HTTP POST request to https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Its JSON body contains one required value, url.

Before writing PHP, make one minimal request from a trusted terminal. Entering the token without placing it directly in the command reduces accidental shell-history exposure.

read -r -s WEBSITE_SECURITY_TOKEN
export WEBSITE_SECURITY_TOKEN

curl --request POST \
  --url https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website \
  --header "Authorization: Bearer ${WEBSITE_SECURITY_TOKEN}" \
  --header "Accept: application/json" \
  --header "Content-Type: application/json" \
  --data '{"url":"https://www.example.com"}'

unset WEBSITE_SECURITY_TOKEN

Now place the production values in an environment file created by your deployment secret store, outside the public document root. Do not commit the real file. A versioned .env.production.example may contain placeholders only.

WEBSITE_SECURITY_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_AUDIT_URL=https://www.example.com

On a conventional server, the real file might be /etc/my-site/security-audit.env, owned by the deployment user and readable only by that account. Native PHP does not automatically load dotenv files; the post-deployment script will explicitly export this file into the command environment.

Choose a small, testable architecture

The project needs four boundaries: a cURL transport, an API client, a domain result, and a command that translates results into deployment exit codes. Keeping cURL outside the API client lets PHPUnit use a deterministic fake without opening network connections.

security-audit/
├── bin/security-audit.php
├── deploy/post-deploy.sh
├── src/Http/CurlTransport.php
├── src/Http/HttpTransport.php
├── src/Security/SecurityAnalyzer.php
├── src/Security/SecurityAuditResult.php
├── tests/SecurityAnalyzerTest.php
└── composer.json

The only production extensions are cURL and JSON. PHPUnit is development-only.

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

Install dependencies with composer install. PHPUnit 11 supports PHP 8.3, while the production code remains independent of an HTTP-client package.

Build a bounded native cURL transport

Create src/Http/HttpTransport.php. The narrow return contract is enough for response mapping, retry decisions, and deterministic tests.

<?php
declare(strict_types=1);

namespace App\Http;

interface HttpTransport
{
    /**
     * @param list<string> $headers
     * @param array<string, mixed> $body
     * @return array{
     *     status: int,
     *     body: string,
     *     headers: array<string, string>
     * }
     */
    public function postJson(
        string $url,
        array $headers,
        array $body,
        int $connectTimeoutSeconds,
        int $timeoutSeconds
    ): array;
}

Create src/Http/CurlTransport.php. Certificate and hostname verification remain enabled, redirects are disabled, and both connection and total response time are bounded.

<?php
declare(strict_types=1);

namespace App\Http;

use RuntimeException;

final class CurlTransport implements HttpTransport
{
    public function postJson(
        string $url,
        array $headers,
        array $body,
        int $connectTimeoutSeconds,
        int $timeoutSeconds
    ): array {
        $handle = curl_init($url);

        if ($handle === false) {
            throw new RuntimeException('Unable to initialize cURL.');
        }

        $responseHeaders = [];

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR),
            CURLOPT_HTTPHEADER => array_merge([
                'Accept: application/json',
                'Content-Type: application/json',
            ], $headers),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => $connectTimeoutSeconds,
            CURLOPT_TIMEOUT => $timeoutSeconds,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_USERAGENT => 'production-security-audit/1.0',
            CURLOPT_HEADERFUNCTION => static function (
                $curl,
                string $line
            ) use (&$responseHeaders): int {
                $length = strlen($line);
                $parts = explode(':', $line, 2);

                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] =
                        trim($parts[1]);
                }

                return $length;
            },
        ]);

        $responseBody = curl_exec($handle);

        if ($responseBody === false) {
            $reason = curl_error($handle);
            throw new RuntimeException('HTTP transport failure: ' . $reason);
        }

        return [
            'status' => (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
            'body' => $responseBody,
            'headers' => $responseHeaders,
        ];
    }
}

Validate the response at the application boundary

The service result includes a score, findings grouped by severity, TLS details, and recommendations. Nested finding and TLS attributes may evolve, so the application validates the stable top-level contract without inventing deeper fields or assuming a score range.

Create src/Security/SecurityAuditResult.php.

<?php
declare(strict_types=1);

namespace App\Security;

use UnexpectedValueException;

final readonly class SecurityAuditResult
{
    public function __construct(
        public float $score,
        public array $findingsBySeverity,
        public array $tls,
        public array $recommendations
    ) {}

    public static function fromPayload(array $payload): self
    {
        if (
            !array_key_exists('score', $payload) ||
            !is_int($payload['score']) && !is_float($payload['score'])
        ) {
            throw new UnexpectedValueException('Invalid or missing score.');
        }

        if (!isset($payload['findings']) || !is_array($payload['findings'])) {
            throw new UnexpectedValueException('Invalid or missing findings.');
        }

        foreach ($payload['findings'] as $severity => $findings) {
            if (!is_string($severity) || !is_array($findings)) {
                throw new UnexpectedValueException(
                    'Findings must be grouped by severity.'
                );
            }
        }

        if (!isset($payload['tls']) || !is_array($payload['tls'])) {
            throw new UnexpectedValueException('Invalid or missing TLS details.');
        }

        if (
            !isset($payload['recommendations']) ||
            !is_array($payload['recommendations'])
        ) {
            throw new UnexpectedValueException(
                'Invalid or missing recommendations.'
            );
        }

        return new self(
            (float) $payload['score'],
            $payload['findings'],
            $payload['tls'],
            $payload['recommendations']
        );
    }
}

Add deliberate retries and failure classification

Create src/Security/SecurityAnalyzer.php. It retries transport errors, HTTP 408, HTTP 429, and server errors. Authentication and ordinary client errors fail immediately because retrying the same invalid request wastes quota and delays the deployment.

<?php
declare(strict_types=1);

namespace App\Security;

use App\Http\HttpTransport;
use Closure;
use JsonException;
use RuntimeException;

final class SecurityAnalyzer
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';

    private Closure $sleep;
    private Closure $log;

    public function __construct(
        private readonly HttpTransport $transport,
        private readonly string $token,
        ?Closure $sleep = null,
        ?Closure $log = null
    ) {
        if (trim($token) === '') {
            throw new RuntimeException('The service token is empty.');
        }

        $this->sleep = $sleep
            ?? static fn (int $microseconds) => usleep($microseconds);

        $this->log = $log
            ?? static fn (array $event) => error_log(
                json_encode($event, JSON_THROW_ON_ERROR)
            );
    }

    public function analyze(string $url): SecurityAuditResult
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->postJson(
                    self::ENDPOINT,
                    ['Authorization: Bearer ' . $this->token],
                    ['url' => $url],
                    5,
                    20
                );
            } catch (RuntimeException $exception) {
                if ($attempt === 3) {
                    throw new RuntimeException(
                        'Analyzer transport failed after retry budget.',
                        0,
                        $exception
                    );
                }

                $this->pause($attempt, 'transport');
                continue;
            }

            $status = $response['status'];

            if ($status >= 200 && $status < 300) {
                try {
                    $payload = json_decode(
                        $response['body'],
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (JsonException $exception) {
                    throw new RuntimeException(
                        'Analyzer returned invalid JSON.',
                        0,
                        $exception
                    );
                }

                if (!is_array($payload)) {
                    throw new RuntimeException(
                        'Analyzer returned an invalid response document.'
                    );
                }

                return SecurityAuditResult::fromPayload($payload);
            }

            if ($status === 401 || $status === 403) {
                throw new RuntimeException(
                    'Analyzer authentication was rejected.'
                );
            }

            $retryable = in_array($status, [408, 429], true)
                || ($status >= 500 && $status <= 599);

            if (!$retryable) {
                throw new RuntimeException(
                    'Analyzer rejected the request with HTTP ' . $status . '.'
                );
            }

            if ($attempt === 3) {
                throw new RuntimeException(
                    'Analyzer remained unavailable after retry budget.'
                );
            }

            $this->pause(
                $attempt,
                'http_' . $status,
                $response['headers']['retry-after'] ?? null
            );
        }

        throw new RuntimeException('Unreachable analyzer state.');
    }

    private function pause(
        int $attempt,
        string $reason,
        ?string $retryAfter = null
    ): void {
        $delay = 250_000 * (2 ** ($attempt - 1));

        if (
            $retryAfter !== null &&
            preg_match('/^\d+$/', trim($retryAfter)) === 1
        ) {
            $delay = min(2, (int) $retryAfter) * 1_000_000;
        }

        ($this->log)([
            'event' => 'security_audit_retry',
            'attempt' => $attempt,
            'reason' => $reason,
            'delay_ms' => intdiv($delay, 1000),
        ]);

        ($this->sleep)($delay);
    }
}

The two-second cap on Retry-After is intentional. A post-deployment hook should respect short quota signals, but it should not occupy a runner indefinitely. Persistent rate limiting becomes an explicit failed audit that can be retried by the pipeline later.

Turn the result into a deployment decision

Create bin/security-audit.php. This policy blocks on groups named high or critical, irrespective of letter case. It deliberately does not gate on the score because the supplied contract does not define a score scale or threshold.

<?php
declare(strict_types=1);

use App\Http\CurlTransport;
use App\Security\SecurityAnalyzer;

require dirname(__DIR__) . '/vendor/autoload.php';

$environment = static function (string $name): string {
    $value = getenv($name);

    if ($value === false || trim($value) === '') {
        throw new RuntimeException('Missing environment variable: ' . $name);
    }

    return trim($value);
};

try {
    $token = $environment('WEBSITE_SECURITY_TOKEN');
    $url = $environment('SECURITY_AUDIT_URL');

    if (
        filter_var($url, FILTER_VALIDATE_URL) === false ||
        parse_url($url, PHP_URL_SCHEME) !== 'https'
    ) {
        throw new RuntimeException(
            'SECURITY_AUDIT_URL must be a valid HTTPS URL.'
        );
    }

    $analyzer = new SecurityAnalyzer(
        new CurlTransport(),
        $token
    );

    $result = $analyzer->analyze($url);
    $counts = [];
    $blockingFindings = 0;

    foreach ($result->findingsBySeverity as $severity => $findings) {
        $counts[$severity] = count($findings);

        if (in_array(strtolower($severity), ['high', 'critical'], true)) {
            $blockingFindings += count($findings);
        }
    }

    fwrite(STDOUT, json_encode([
        'event' => 'security_audit_completed',
        'target' => $url,
        'score' => $result->score,
        'findings_by_severity' => $counts,
        'tls_detail_keys' => array_keys($result->tls),
        'recommendation_count' => count($result->recommendations),
        'blocking_findings' => $blockingFindings,
    ], JSON_THROW_ON_ERROR) . PHP_EOL);

    exit($blockingFindings > 0 ? 10 : 0);
} catch (Throwable $exception) {
    fwrite(STDERR, json_encode([
        'event' => 'security_audit_failed',
        'error_type' => $exception::class,
        'message' => $exception->getMessage(),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);

    exit(20);
}

Exit code 0 means the audit completed without blocking findings. Code 10 means the analysis completed but violated release policy. Code 20 represents configuration, transport, authentication, quota, response-format, or service failures. This distinction lets a deployment platform roll back for findings while treating analyzer unavailability as an operational incident instead of evidence that the release itself is insecure.

Test without calling the service

The fake transport records the outgoing request and returns a fixed queue. Injected sleep prevents retry tests from pausing. Create tests/SecurityAnalyzerTest.php.

<?php
declare(strict_types=1);

namespace Tests;

use App\Http\HttpTransport;
use App\Security\SecurityAnalyzer;
use PHPUnit\Framework\TestCase;
use RuntimeException;

final class FakeTransport implements HttpTransport
{
    public array $requests = [];

    public function __construct(private array $responses) {}

    public function postJson(
        string $url,
        array $headers,
        array $body,
        int $connectTimeoutSeconds,
        int $timeoutSeconds
    ): array {
        $this->requests[] = compact(
            'url',
            'headers',
            'body',
            'connectTimeoutSeconds',
            'timeoutSeconds'
        );

        return array_shift($this->responses);
    }
}

final class SecurityAnalyzerTest extends TestCase
{
    public function testItMapsAValidResponse(): void
    {
        $fake = new FakeTransport([[
            'status' => 200,
            'headers' => [],
            'body' => json_encode([
                'score' => 87,
                'findings' => ['high' => [['issue' => 'example']]],
                'tls' => ['enabled' => true],
                'recommendations' => ['Review the reported issue'],
            ], JSON_THROW_ON_ERROR),
        ]]);

        $result = (new SecurityAnalyzer(
            $fake,
            'test-token',
            static fn (int $delay) => null,
            static fn (array $event) => null
        ))->analyze('https://www.example.com');

        self::assertSame(87.0, $result->score);
        self::assertCount(1, $result->findingsBySeverity['high']);
        self::assertSame(
            ['url' => 'https://www.example.com'],
            $fake->requests[0]['body']
        );
        self::assertContains(
            'Authorization: Bearer test-token',
            $fake->requests[0]['headers']
        );
    }

    public function testItRetriesAServerFailureOnce(): void
    {
        $validBody = json_encode([
            'score' => 90,
            'findings' => [],
            'tls' => [],
            'recommendations' => [],
        ], JSON_THROW_ON_ERROR);

        $fake = new FakeTransport([
            ['status' => 503, 'headers' => [], 'body' => ''],
            ['status' => 200, 'headers' => [], 'body' => $validBody],
        ]);

        $sleeps = [];

        $result = (new SecurityAnalyzer(
            $fake,
            'test-token',
            static function (int $delay) use (&$sleeps): void {
                $sleeps[] = $delay;
            },
            static fn (array $event) => null
        ))->analyze('https://www.example.com');

        self::assertSame(90.0, $result->score);
        self::assertCount(2, $fake->requests);
        self::assertSame([250000], $sleeps);
    }

    public function testItDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new FakeTransport([[
            'status' => 401,
            'headers' => [],
            'body' => '',
        ]]);

        try {
            (new SecurityAnalyzer(
                $fake,
                'bad-token',
                static fn (int $delay) => null,
                static fn (array $event) => null
            ))->analyze('https://www.example.com');

            self::fail('Expected authentication failure.');
        } catch (RuntimeException $exception) {
            self::assertStringContainsString(
                'authentication',
                $exception->getMessage()
            );
            self::assertCount(1, $fake->requests);
        }
    }
}

Run the suite with vendor/bin/phpunit tests. These tests verify request mapping, authentication, retry backoff, response mapping, and the important absence of retries after rejected credentials.

Run it after every production deployment

The check must run after traffic reaches the new release; otherwise it analyzes the previous version. The following deploy/post-deploy.sh loads the protected environment file, waits for bounded public readiness, and invokes the audit.

#!/usr/bin/env sh
set -eu

RELEASE_DIR=${1:?Pass the deployed release directory}
AUDIT_ENV_FILE=/etc/my-site/security-audit.env

if [ ! -r "$AUDIT_ENV_FILE" ]; then
  echo "Security audit environment file is not readable." >&2
  exit 20
fi

set -a
. "$AUDIT_ENV_FILE"
set +a

cd "$RELEASE_DIR"

ready=0
attempt=1

while [ "$attempt" -le 5 ]; do
  if curl --fail --silent --show-error \
    --max-time 10 "$SECURITY_AUDIT_URL" >/dev/null; then
    ready=1
    break
  fi

  sleep $((attempt * 2))
  attempt=$((attempt + 1))
done

if [ "$ready" -ne 1 ]; then
  echo "Production URL did not become ready." >&2
  exit 30
fi

php bin/security-audit.php

Call this script from the deployment platform immediately after promotion. Preserve its exit code. Configure an alert for codes 10, 20, and 30; connect automatic rollback only to policies your team has consciously approved. Allow enough job time for three 20-second analyzer attempts plus readiness checks and short backoffs.

Security and operational hardening

  • Keep the token out of Git, command arguments, logs, fixtures, screenshots, and public build artifacts.
  • Restrict the environment file to the deployment account and keep it outside the web root.
  • Never disable TLS verification to work around certificate errors.
  • Use a deployment-controlled target URL. Do not accept an arbitrary URL from a public request and forward it to the analyzer.
  • Log status, attempt, duration, target, score, and finding counts, but keep detailed findings in an access-controlled artifact or security system.
  • Alert separately on authentication failures, repeated rate limits, malformed responses, and security-policy failures.
  • When regenerating the service token, update the secret store because the previous active token is revoked.

Common production failures

An HTTP 401 or 403 usually means the wrong token was installed, the token was regenerated, or the selected service was not activated. Correct the credential rather than increasing retries.

An HTTP 429 indicates quota or rate limiting. The client honors a short numeric Retry-After value, then exits after its bounded retry budget. Check plan capacity and ensure parallel deployments are not launching duplicate audits.

A transport timeout can mean DNS trouble, an outbound firewall rule, or temporary service unavailability. The command retries, but it never converts exhaustion into a passing result. Invalid JSON or a changed top-level contract also fails closed at the integration boundary.

If the public readiness probe succeeds but the audit reports unexpected TLS or browser findings, verify that the analyzed hostname is the production hostname. Auditing an origin server, staging URL, or alternate domain can legitimately produce a different result from the customer-facing site.

Final verification checklist

  • The active plan is Free, Plus, or Pro, and the service-scoped token comes from the documentation page’s Service token panel.
  • The deployment secret contains WEBSITE_SECURITY_TOKEN and an HTTPS SECURITY_AUDIT_URL.
  • vendor/bin/phpunit tests passes without network access.
  • The deployed release is publicly reachable before the analyzer command runs.
  • A successful audit exits with 0; blocking findings exit with 10; operational failures exit with 20.
  • Retries are bounded, authentication errors are not retried, and HTTP 429 is handled explicitly.
  • Logs contain useful summaries but never the token or raw authorization header.
  • The team understands that this is a public security-posture check, not a penetration test.

The valuable shift is small but durable: deployment success now includes what the public internet can observe. Code, configuration, TLS, and browser defenses meet at that boundary. Checking it after every release turns security posture from an occasional manual inspection into an ordinary, testable part of shipping software.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.