Chain Verification

A hash chain is only useful if you regularly verify it. AuditChain ships with an Artisan command and a programmatic API that re-derive every hash in the chain and flag any mismatches — catching tampering, data corruption, or accidental modifications.

The audit:verify Command

# Verify the entire chain
php artisan audit:verify

# Filter by model type
php artisan audit:verify --type="App\Models\User"

# Filter by model type and ID
php artisan audit:verify --type="App\Models\User" --id=42

# Verify and send notifications on failure
php artisan audit:verify --notify

Flags

Flag Description
--type= Scope verification to a single Eloquent model class (e.g. App\Models\Invoice).
--id= Combined with --type, scope to a single model instance.
--notify Send mail and/or webhook notifications if the chain is compromised.

Filtered vs. Full Verification

AuditChain uses a global chain — a single hash chain that spans all auditable models. When you run audit:verify without filters, two checks are performed on every entry:

  1. Hash integrity — recompute the hash from the stored payload and compare it to the stored hash.
  2. Chain continuity — confirm that the entry's prev_hash matches the previous entry's hash.

When you pass --type or --id, only hash integrity is checked. Chain continuity is skipped because the filtered subset does not contain every link in the global chain.

How Verification Works

Verification processes audit logs in chunks of 1,000, ordered by id ascending (ULIDs are monotonic, so id ordering is sufficient). Verification terminates early after maxErrors failures (default: 100) to avoid processing an entire corrupted chain. For each entry the service:

  1. Reads the raw database values (bypassing Eloquent casts to avoid format discrepancies).
  2. Normalizes the created_at timestamp to UTC.
  3. Sorts the old_values, new_values, and personal_data_accessed arrays for deterministic key order.
  4. Builds the same canonical payload that was used at recording time.
  5. Computes SHA-256 over the JSON-encoded payload.
  6. Compares the result against the stored hash.

If this is an unfiltered run, it also checks that the entry's prev_hash equals the previous entry's hash (or the genesis hash for the first entry).

Checkpoint-Based Verification

If chained audit logs have been pruned with --include-chained, the audit_chain_state.checkpoint_hash stores the hash of the last deleted entry. Verification uses this checkpoint as the starting point instead of the genesis hash, so the remaining chain can still be verified successfully.

Programmatic API

Use AuditChainService directly when you need to verify the chain in application code — for example inside a health check endpoint or a custom command:

use GrayMatter\AuditChain\Services\AuditChainService;

$service = app(AuditChainService::class);

// Full chain
$result = $service->verifyChain();

// Scoped to a model type
$result = $service->verifyChain(type: 'App\Models\Invoice');

// Scoped to a single record
$result = $service->verifyChain(type: 'App\Models\Invoice', id: '42');

// Limit early termination (default: 100 errors)
$result = $service->verifyChain(maxErrors: 50);

The return value is an array:

[
    'valid'   => true,   // bool -- true if no errors found
    'checked' => 150,    // int  -- number of entries verified
    'errors'  => [],     // array of ['id' => '...', 'message' => '...']
]

Example: Health Check Endpoint

Route::get('/health/audit-chain', function () {
    $result = app(AuditChainService::class)->verifyChain();

    return response()->json($result, $result['valid'] ? 200 : 500);
});

Scheduling Verification

For production systems, schedule audit:verify --notify so you are alerted automatically when tampering is detected. In Laravel 11+, add the schedule to routes/console.php:

use Illuminate\Support\Facades\Schedule;

// Verify the chain every hour and notify on failure
Schedule::command('audit:verify --notify')->hourly();

Adjust the frequency based on how much latency you can tolerate between a tamper event and detection. For high-security environments, consider running it every 15 minutes:

Schedule::command('audit:verify --notify')->everyFifteenMinutes();

Notifications

When --notify is passed and verification fails, AuditChain sends alerts through the channels configured in config/audit-chain.php:

'notifications' => [
    'channels' => ['mail', 'webhook'],
    'mail_to'  => [env('AUDIT_ALERT_EMAIL', '')],
    'webhooks' => [
        env('AUDIT_ALERT_WEBHOOK_1'),
    ],
],
  • Mail — sends a ChainCompromised notification to the configured addresses.
  • Webhook — POSTs a JSON payload to each URL. The payload includes both text and content keys for compatibility with Slack, Microsoft Teams, Discord, and custom endpoints.

The webhook payload includes the error count, total entries checked, and up to 5 individual error details.

What to Do When Verification Fails

A verification failure means one or more audit log entries have been modified or the chain has been broken. Treat this as a security incident:

  1. Investigate immediately — the error output includes the ULID of each affected entry.
  2. Check database access logs — look for direct UPDATE or DELETE queries against the audit table.
  3. Review application logs — check for unexpected deployments, migrations, or seed operations.
  4. Preserve evidence — take a database snapshot before making any changes.
  5. Notify stakeholders — depending on your compliance requirements (GDPR Art. 33, NIS2 Art. 21), you may need to report the incident.