Audit Log Architecture for B2B SaaS: Tamper-Evident Logs Enterprise Buyers Actually Trust

#audit log architecture SaaS
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Somewhere in your enterprise sales pipeline, there is a security questionnaire asking: "Does your platform maintain a tamper-evident audit trail of all user and system actions?" If your answer is "yes, we log changes to our database," you are about to lose the deal.

Audit log architecture for SaaS is one of those topics that looks simple on the surface — just record what happened, right? — until an enterprise client's security team asks for cryptographic proof that no log entry was modified after the fact. Or your legal team needs a forensic export of every action a specific employee took across all their customers' accounts over the last 18 months. Or a GDPR deletion request forces you to remove personal data from your audit trail without destroying its continuity.

At that point, a created_at column on your audit_events table is not going to cut it.

This guide covers how to design a production-grade audit log system in Symfony and PostgreSQL that satisfies both your enterprise buyers and your compliance obligations — without bolting on a specialist vendor or building something unmaintainable.

Why Most SaaS Audit Logs Fail Enterprise Review

The typical SaaS audit log is a byproduct of the application — a side table that accumulates rows when someone remembers to write to it. These logs fail enterprise security reviews for predictable reasons.

They are mutable. Any database administrator with UPDATE privileges can silently alter a log entry. Enterprise security teams know this. When they ask for "tamper-evident" logs, they mean they want mathematical proof that what you show them today matches what was recorded at the time.

They lack actor context. Logging that user_id: 483 updated a record tells a security auditor very little. Who was acting on behalf of user_id: 483? Was it the user directly, an API key, an internal automated job, a customer support agent impersonating the user? Without that attribution chain, your logs cannot answer the questions that actually matter during an incident.

They lose context across async boundaries. When a webhook fires a background worker that triggers an email notification that updates three records — what is the actor for those downstream events? Most codebases record nothing, or record the system user, which destroys the audit trail for the original human action.

They cannot satisfy GDPR and retention requirements simultaneously. A GDPR deletion request asks you to erase personal data. A SOC 2 retention requirement asks you to keep audit events for a defined period. If your audit log rows contain raw personal data (names, email addresses, raw field values), erasing a user means destroying audit history. These objectives are not irreconcilable, but they require deliberate design.

The Four Pillars of a Production Audit Log

1. Append-Only Storage

The first architectural decision is the most important: your audit log table must be append-only. No application code should ever issue an UPDATE or DELETE against it.

In PostgreSQL, you can enforce this at the database level using a trigger:

CREATE TABLE audit_events (
    id          BIGSERIAL PRIMARY KEY,
    occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    tenant_id   UUID NOT NULL,
    actor_type  TEXT NOT NULL,
    actor_id    TEXT NOT NULL,
    actor_chain JSONB,
    action      TEXT NOT NULL,
    resource    TEXT NOT NULL,
    resource_id TEXT,
    metadata    JSONB,
    prev_hash   TEXT,
    event_hash  TEXT NOT NULL
);

CREATE OR REPLACE FUNCTION prevent_audit_modification()
RETURNS TRIGGER AS $$
BEGIN
    RAISE EXCEPTION 'Audit events are immutable';
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER audit_events_no_update
    BEFORE UPDATE OR DELETE ON audit_events
    FOR EACH ROW EXECUTE FUNCTION prevent_audit_modification();

This means the database itself rejects modification attempts — even from a migration script running as a superuser would need to explicitly drop the trigger first, which leaves its own trace in the PostgreSQL log. That is the level of protection enterprise buyers are looking for.

Revoke TRUNCATE from your application database role as well. TRUNCATE bypasses row-level triggers.

2. Cryptographic Chaining for Tamper Evidence

Append-only storage prevents modification, but it does not prove that entries have not been deleted and recreated. Cryptographic chaining ties each event to the one before it.

For each new event, compute a hash that includes the previous event's hash:

final class AuditEventHasher
{
    public function computeHash(AuditEvent $event, ?string $previousHash): string
    {
        $payload = implode('|', [
            $event->occurredAt->format('U.u'),
            $event->tenantId,
            $event->actorType,
            $event->actorId,
            $event->action,
            $event->resource,
            $event->resourceId ?? '',
            json_encode($event->metadata, JSON_THROW_ON_ERROR),
            $previousHash ?? 'GENESIS',
        ]);

        return hash('sha256', $payload);
    }
}

When you persist a new event, fetch the hash of the most recent event for that tenant, compute the new event's hash, and store both:

$previousHash = $this->auditRepository->getLatestHashForTenant($tenantId);
$event->prevHash = $previousHash;
$event->eventHash = $this->hasher->computeHash($event, $previousHash);
$this->entityManager->persist($event);

A forensic export can now be verified client-side: recompute every hash in sequence and confirm the chain is unbroken. Any gap, deletion, or modification produces a chain break that is immediately detectable. This is the mathematical proof enterprise security teams ask for.

For multi-tenant systems, maintain separate chains per tenant. This prevents cross-tenant contamination and allows per-customer forensic exports that stand alone.

3. Actor Attribution Across Async Boundaries

The hardest part of audit log architecture is not the storage — it is propagating actor context across async workers, webhooks, and background jobs.

Introduce an actor_chain concept. When a human user triggers an action, the actor chain contains just that user. When a background job runs as a consequence of that action, the chain records both the original human actor and the automated system, preserving causal attribution:

final class AuditActorContext
{
    private array $chain = [];

    public function setDirectActor(string $type, string $id, array $metadata = []): void
    {
        $this->chain = [
            ['type' => $type, 'id' => $id, 'metadata' => $metadata, 'role' => 'principal'],
        ];
    }

    public function pushSystemActor(string $type, string $id): void
    {
        $this->chain[] = ['type' => $type, 'id' => $id, 'role' => 'system'];
    }

    public function getChain(): array { return $this->chain; }

    public function getPrimaryActor(): array
    {
        return $this->chain[0] ?? ['type' => 'system', 'id' => 'unknown', 'role' => 'system'];
    }
}

When dispatching a Symfony Messenger message, serialize the actor chain into the envelope as a stamp:

use Symfony\Component\Messenger\Stamp\StampInterface;

final readonly class AuditActorStamp implements StampInterface
{
    public function __construct(public readonly array $actorChain) {}
}

// When dispatching:
$this->bus->dispatch(
    new ProcessWebhookMessage($payload),
    [new AuditActorStamp($this->actorContext->getChain())]
);

In the message handler, restore the context before any work is done:

final class ProcessWebhookMessageHandler
{
    public function __invoke(ProcessWebhookMessage $message, Envelope $envelope): void
    {
        $stamp = $envelope->last(AuditActorStamp::class);
        if ($stamp) {
            $this->actorContext->restoreChain($stamp->actorChain);
            $this->actorContext->pushSystemActor('worker', 'webhook-processor');
        }
        // handler logic
    }
}

Every audit event written inside that handler now carries the full attribution chain: the original API key or user session that triggered the webhook, and the background worker that processed it. The same pattern handles customer support impersonation — push both the support agent and the target user into the chain so your audit log shows exactly who acted on whose behalf.

4. Satisfying GDPR and SOC 2 Simultaneously

The conflict between GDPR deletion rights and SOC 2 retention requirements resolves when you stop storing raw personal data directly in audit event rows.

Store a reference to a separate audit_actors table and pseudonymize at export time:

CREATE TABLE audit_actors (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id    UUID NOT NULL,
    external_id  TEXT NOT NULL,
    display_name TEXT,
    email        TEXT,
    erased_at    TIMESTAMPTZ,
    UNIQUE(tenant_id, external_id)
);

Audit events reference audit_actors.id rather than storing names or email addresses in the event row. When a GDPR deletion request arrives: set erased_at, null out display_name and email, and leave every audit event row untouched — the chain remains intact. When generating an export, substitute [Deleted User] for any actor where erased_at IS NOT NULL. Audit trail continuity is preserved; personal data is gone. Both requirements satisfied.

For retention, partition audit_events by month. When a partition ages out of your retention window, drop it cleanly — the remaining chain is unaffected, and the expiry is documented expected behavior. Automate partition creation in your deployment pipeline and partition dropping in a scheduled Symfony command.

Export Patterns That Pass Security Reviews

Enterprise clients will eventually ask for a forensic export. Design for this from day one. Your export should include all events in time-ordered sequence for the requested tenant and date range, the prev_hash and event_hash for every row to enable offline verification, a verification manifest with the genesis hash, terminal hash, and total event count, and pseudonymized actor data with a note on any erased actors.

Provide a standalone verification script alongside the export — a simple Python or Node script that recomputes the hash chain from the raw data and confirms integrity. Handing a security team a self-contained verification tool signals maturity and builds trust far more effectively than any certifications document.

A well-designed audit log record for a permission change looks like this:

{
  "id": 98234,
  "occurred_at": "2026-05-18T09:14:33.421Z",
  "tenant_id": "acme-corp",
  "actor_type": "user",
  "actor_id": "usr_k9x2m",
  "actor_chain": [
    {"type": "user", "id": "usr_k9x2m", "role": "principal"},
    {"type": "api_key", "id": "key_j7p3n", "role": "credential"}
  ],
  "action": "user.role.updated",
  "resource": "user",
  "resource_id": "usr_4vr8t",
  "metadata": {
    "previous_role": "viewer",
    "new_role": "admin"
  },
  "prev_hash": "a3f9c2d...",
  "event_hash": "b7e4a1c..."
}

An enterprise security reviewer can immediately answer: who made the change, acting via which credential, what changed, what the previous state was, and how this event fits into the verifiable chain of all actions in that tenant's history.

Performance at Scale

A high-traffic B2B SaaS platform can generate tens of thousands of audit events per hour. Three decisions prevent the audit log from becoming a bottleneck.

Write asynchronously. The primary request path should not block on audit log writes — dispatch a Symfony Messenger message to persist the event. For financial or compliance-critical flows where you cannot tolerate the async window, use a Doctrine onFlush listener to piggyback the audit write on the same transaction as the business event.

Index selectively. The most common query patterns are all events for a tenant in a date range, all events for a specific resource, and all events by a specific actor. Index (tenant_id, occurred_at) as your primary composite index. Add a partial index on (tenant_id, resource, resource_id) for resource lookups. Avoid indexing every column — write overhead compounds fast on a high-volume append-only table.

Separate your audit database. For applications beyond early-growth stage, the audit log should live in a separate PostgreSQL database or at minimum a separate schema with its own connection pool. This isolates write performance from your operational database and allows you to apply different backup, retention, and access control policies cleanly.

Getting Started

If you are retrofitting this into an existing Symfony application, begin with the append-only trigger and actor context propagation. Those two changes alone move your audit log from a liability into an asset. Add cryptographic chaining in a second pass once your data model stabilizes.

If you are evaluating whether your current audit log would survive an enterprise security review, work through three questions: Can you prove no entry was modified after the fact? Can you trace any audit event back to the original human actor, even if it was written by a background job? Can you satisfy a GDPR deletion request without destroying audit trail continuity?

If the honest answer to any of those is no, your next enterprise prospect's security team will find out before you close the deal.

Building audit infrastructure that enterprise buyers trust is part of the code quality and architecture work we do regularly with B2B SaaS teams. If your current audit log is holding up a procurement process, or you are designing a new system and want to get the architecture right from the start, reach out at hello@wolf-tech.io or visit wolf-tech.io.


Frequently Asked Questions

Does every SaaS application need cryptographic chaining?

Not every application, but any B2B SaaS targeting mid-market or enterprise customers where security questionnaires are part of the sales process should have it. The implementation cost is low — a hash column and a few lines of application logic — and the trust signal it sends is disproportionately high relative to that effort.

How do we handle the genesis event — the first entry in the chain?

The first event for a tenant uses GENESIS as its prev_hash input. Document this in your verification manifest so auditors know what to expect when they recompute the chain from scratch.

What happens if a background job fails halfway through?

Partial writes can break chain verification. Use a Symfony Messenger failure transport and ensure that retried jobs do not re-emit audit events for work already completed. Idempotency keys on audit events — based on a deterministic identifier for the action — prevent duplicate entries on retry.

Can we use an external service like Datadog or Splunk instead?

External logging services are appropriate for operational logs — errors, performance metrics, debug traces. For compliance audit logs that enterprise buyers will want to export and verify independently, you want a first-party system where you control data residency, retention policy, and export format. Vendor lock-in in your audit trail is a harder conversation than it sounds when a customer asks for 36 months of history in a specific schema.

How do we handle multi-region deployments where events might arrive out of sequence?

Use a logical sequence number per tenant — a monotonically increasing counter — rather than relying on occurred_at timestamps for chain ordering. Timestamps across regions can skew. Sequence numbers from a PostgreSQL sequence or a distributed counter provide deterministic ordering for hash chain computation.