Sphragis - The EU AI Act Compliance Gateway You Actually Control
~/posts/introducing-sphragis-eu-ai-act-compliance-gateway.md17 min · 3410 words

Sphragis - The EU AI Act Compliance Gateway You Actually Control

// Sphragis is a self-hosted Go gateway that strips PII out of every LLM request and response before it leaves your network and writes a tamper-evident, hash-chained audit log. A walkthrough of the v0.3.0 release: local redaction, reversible tokenization, multi-provider routing, and OpenTimestamps anchoring.

$ date

It is 02:00 and you are on call. Checkout is throwing 500s. You do not tail logs by hand anymore, you ask Claude Code, and it calls your Datadog MCP server, pulls the failing traces and the surrounding log lines, and reasons over them to find the bad deploy. One of those log lines is a request payload with a real customer’s email and IBAN in it, because production telemetry carries that. The MCP handed it to the model as context. You never saw it. Nobody decided to send that customer’s bank details to a US provider. The agent fetched it and shipped it upstream, the way it does on every investigation.

Here is the part teams get wrong. “Our developers never touch real customer data” is true and beside the point. They do not need to, and increasingly they do not even do the touching: an agent does, through whatever MCP servers and tools you wired into it. Your logs, traces, error reports, and the production LLM features themselves are where personal data actually flows, and it flows to whichever model the agent or the app calls. Access controls on the customer database do nothing for the prompt that just left your network.

Months later your DPO asks the question every DPO eventually asks: what personal data have we sent to which model providers, and can you prove it? You cannot. There is no record. The prompts are gone, scattered across three vendors’ retention windows, and you are reconstructing them from memory in a meeting you are losing.

The EU AI Act and GDPR pull you two ways at once: keep personal data out of third-party model providers, and be able to prove exactly what you sent and when. Most teams reach for the same “fix”, a third-party SaaS scrubber that sits in the request path and redacts PII before it hits OpenAI or Anthropic. Read that again. You solved a data-residency problem by handing your prompts to another processor.

That bothered me enough to build the thing I actually wanted: a gateway that runs inside my own trust boundary, redacts personal data locally, and keeps a tamper-evident record I can hand to an auditor without ever exposing the prompts themselves.

This post walks through Sphragis and its v0.3.0 release. Every command and every block of output below was captured from the binary running on my machine against a local upstream, so what you see is what it actually does.

Who Should Read This?#

This post is for:

  • SREs and platform engineers who have to put guardrails in front of Claude Code, Codex, or in-house LLM apps and need an audit trail that survives a compliance review
  • Security-minded teams in regulated environments (finance, health, EU public sector) who cannot send raw PII to a US model provider
  • Anyone running AI coding agents who has quietly wondered what is in the prompts those tools ship off every few seconds

What is Sphragis?#

Sphragis is a single Go binary that sits between your apps and any OpenAI- or Anthropic-compatible LLM, with first-class support for Claude (Claude Code and the Anthropic SDKs). It does two jobs before a single byte leaves your network:

  1. Redacts PII and secrets locally in both the request and the model’s response, replacing them with stable [KIND_n] tokens.
  2. Appends a tamper-evident, hash-chained record of every call to a local append-only log. If the audit write fails, the gateway fails closed and refuses to forward the request.

The name is the Greek σφραγίς (sphragís), the seal pressed into wax to prove a document is authentic and untampered. That is exactly what the audit log does.

Why this matters: there is no SaaS in the data path. Only an opaque hash ever leaves your network, and only if you opt into public anchoring. Your prompts never reach me. There is no “me” in the data path.

To be precise about that 02:00 scenario: Sphragis redacts the model call, the request your agent makes to Anthropic or OpenAI. When the Datadog MCP feeds those log lines into the model as context, the context rides along inside that request, and Sphragis strips it there. It does not intercept the MCP server’s own traffic, and it does not need to: everything the agent actually sends to the model passes through the gateway.

Why Not a SaaS Scrubber or Just Roll Your Own?#

AspectSaaS scrubberDIY regex in your appSphragis
Data pathYour prompts hit a third partyStays localStays local
Request + response redactionVariesYou build bothBoth, built in
Audit trailTheir format, their serversYou design itHash-chained, local, tamper-evident
Proof of integrityTrust their dashboardNoneverify + OpenTimestamps anchoring
Multi-providerPer-vendor integrationsPer-client wiringOne gateway, path-based routing
Lock-inTheir APIYour tech debtApache 2.0, no license key

Bottom line: if a regulator asks “what did you send to Anthropic on March 3rd, and can you prove the log was not edited after the fact?”, a SaaS scrubber makes you trust someone else’s word and a DIY regex gives you nothing. Sphragis answers both from inside your own boundary.

What Sphragis is NOT#

  • Not a SaaS. There is no hosted control plane in the data path. You run the binary.
  • Not a DLP suite or a firewall. It redacts LLM traffic on known wire formats; it is not a general egress proxy.
  • Not a model router for cost or quality. Routing is by request path to the right provider, not by price or latency.
  • Not magic NER out of the box. Regex and secret detectors are built in; arbitrary names and addresses need the custom-terms file or an external NER service.

Core Concepts#

flowchart TB
    subgraph trust["Your machine - trust boundary"]
        direction TB
        A["App / SDK / agent<br/>Claude Code · Anthropic SDK · Codex"]
        G{{"Sphragis gateway"}}
        R["Redact PII + secrets<br/>EMAIL_1, CARD_2, IBAN_1 ..."]
        L[("Audit log<br/>hash-chained .jsonl")]
        A -->|request| G
        G --> R
        R -->|"append · fails closed"| L
        G -->|"redacted response"| A
    end

    R ==>|"redacted body only"| P["LLM provider<br/>OpenAI · Anthropic"]
    P ==>|response| G

    L -.->|"sphragis verify"| V{"chain intact?"}
    L -.->|"sphragis anchor<br/>opaque root only"| O[("OpenTimestamps<br/>.ots proof")]

    classDef ext stroke-dasharray:5 4;
    class P,O ext;

Four ideas do all the work:

  • Path-based dispatch. The gateway recognizes the wire format from the request path (/v1/messages for Anthropic, /v1/chat/completions for OpenAI, and more), so one instance covers Claude Code and Codex at the same time.
  • Stable tokenization. The same value always maps to the same token, so the model can still reason about “the same person” without ever seeing them. By default tokens are stable within a text field; turn on the vault and they become gateway-global and unique (Example 4 shows what that changes).
  • The hash chain. Each record folds the previous record’s hash into its own, so the log is only valid as a sequence. More on the mechanics below.
  • Fail closed. The redacted request is only forwarded after the audit record is written. No record, no call.

How the hash chain works#

Every record carries a payload_hash, the SHA-256 of the redacted body, and its own hash. That hash is not just a digest of the record; it is a digest of the record plus the previous record’s hash (internal/audit/log.go):

hash = sha256(seq · time · method · path · model · payload_hash · pii_digest · prev_hash)

pii_digest is a deterministic serialization of the redaction counts, so even the record of what kind of data was stripped is chained and cannot be edited after the fact. The first record’s prev_hash is 64 zeros; every record after that points at the one before it, the same shape as a Git commit chain or a blockchain.

Look at the record from Example 1: prev_hash is all zeros (it is seq 1), and its hash is what seq 2 will hash into. That single dependency is what makes the three classic attacks fail:

AttackWhy it breaks
Edit a record (change the model, a count, a payload)Its hash no longer matches the recomputed chainHash; verify stops at that seq
Reorder two recordsEach one’s prev_hash now points at the wrong predecessor; the chain no longer links
Drop a record from the middleThe next record’s prev_hash references a hash that is no longer present; the link dangles

verify recomputes every link from seq 1 forward and prints the Merkle root of the payload hashes. To forge a clean log you would have to recompute every record from the tampered point to the end, and if you anchored the old root (below), even that is caught.

What Gets Redacted#

The status command reports 18 builtin redactors. They cover the patterns you can match reliably:

KindTokenMatcher
Email[EMAIL_n]RFC-ish address pattern
Phone[PHONE_n]+CC NN NNNNN international form
IBAN[IBAN_n]country code + check digits + groups
Card[CARD_n]13-19 digit PAN, Luhn-validated
SSN[SSN_n]US NNN-NN-NNNN
IP[IP_n]IPv4 address
Secret[SECRET_n]value after password/secret/api_key/token, and Bearer tokens
API key[APIKEY_n]OpenAI/Anthropic, AWS, GitHub, Google, Slack, Stripe, SendGrid
Private key[PRIVATEKEY_n]PEM BEGIN ... PRIVATE KEY blocks
JWT[JWT_n]three base64url segments
Custom names[NAME_n]your own term list (SPHRAGIS_CUSTOM_TERMS_FILE)
Name / Address / Health[NAME_n] [ADDRESS_n] [HEALTH_n]optional external NER service

Arbitrary names and addresses cannot be matched by regex. Point SPHRAGIS_NER_URL at an NER service (a Microsoft Presidio sidecar, for example) and the gateway tokenizes the spans it returns. NER is best-effort and fails open, so an NER outage never blocks regex redaction.

Quick Start#

make build

export SPHRAGIS_UPSTREAM_BASE_URL=https://api.openai.com
export SPHRAGIS_UPSTREAM_API_KEY=sk-...   # your real provider key
./sphragis serve                          # foreground, listens on :8787

Point any OpenAI SDK at http://localhost:8787/v1, or Claude Code at the gateway:

export ANTHROPIC_BASE_URL=http://localhost:8787

That is the whole integration. Your client does not change; its base URL does.

Practical Examples#

Examples 1 to 3 run in the default mode with the vault off, so tokens are stable within each field and numbering restarts per field (that is why the Example 2 response starts again at [EMAIL_1]). Example 4 turns the vault on and shows the gateway-global behavior.

Example 1: Request redaction and the audit record#

Send a request with an email, an IBAN, and a card number:

curl -s http://localhost:8787/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o","messages":[{"role":"user",
       "content":"Email jane@example.com, my IBAN is GB82WEST12345698765432 and card 4111111111111111"}]}'

The upstream provider never sees any of those values. The audit log records what kind of data was redacted, never the data itself:

{"seq":1,"time":"2026-06-14T07:29:41.249514Z","method":"POST","path":"/v1/chat/completions","model":"gpt-4o","pii_redacted":{"CARD":1,"EMAIL":1,"IBAN":1},"payload_hash":"6c4e22ac703701ab1ddb58c571f7b8c512273e39bab1eb33830b91b48cbe69da","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","hash":"e623ed578a2a2f134ff9f52551e42780ed191047611b29ac1457773e94cad460"}

Result: the provider received [EMAIL_1], [IBAN_1], and [CARD_1]. The log proves three values were stripped, with no way to recover them from the record.

Example 2: Response redaction (new in v0.3.0)#

The model can leak PII back at you just as easily as you can send it. As of v0.3.0, Sphragis scans the response too. When the upstream returns:

Sure, I will email john.doe@acme.com about invoice from card 4111 1111 1111 1111.

the client actually receives:

Sure, I will email [EMAIL_1] about invoice from card [CARD_1].

This covers JSON responses and streamed stream: true SSE bodies. For streams, assistant text is buffered across chunks and flushed at line boundaries, so a value split across two SSE deltas (jo then hn@x.com) is still tokenized before it lands in your app or its logs.

Example 3: Verify the chain, and catch tampering#

./sphragis verify ~/.sphragis/audit.jsonl
OK: 3 records, chain intact
merkle_root: d90254ba4ed6f0b52868ea86a7af632527b88630969ffb7f87f1e866d4fe000f

Now edit a single field in record 2, say flip the model name, and verify again:

error: seq 2: hash mismatch, record tampered

Result: exit code 1, and the offending sequence number is named. You cannot quietly rewrite history in this log.

Example 4: Reversible tokenization with the sealed vault (new in v0.3.0)#

By default nothing is reversible: originals are never stored. But sometimes you legitimately need to rehydrate a redacted document inside your own boundary (debugging, a support ticket, a human review). Set a 32-byte key and the gateway records each token’s original value in a local vault sealed with AES-256-GCM:

export SPHRAGIS_VAULT_KEY=$(openssl rand -base64 32)
./sphragis serve

With the vault on, tokens become gateway-global and unique: every distinct value gets its own number across the whole gateway, not just within one field.

What changes: with the vault off, a request and a response are tokenized independently, so both can start at [EMAIL_1]. With it on, jane@example.com is [EMAIL_1] everywhere and a different address in the response becomes [EMAIL_2]. Two people never share a token, and one person keeps theirs across calls, which is exactly what reveal needs to map back.

Restore originals with reveal, which only works with the key, inside the trust boundary:

echo "Please contact [EMAIL_1] and pay [CARD_1]." | ./sphragis reveal
Please contact jane@example.com and pay 4111 1111 1111 1111.

Result: reversibility is opt-in and key-gated. Without the key, there is nothing to reveal because no originals were ever written.

Example 5: One gateway, both providers (new in v0.3.0)#

A single instance now routes by request path, sending Anthropic paths to the Anthropic upstream and OpenAI paths to the OpenAI upstream:

export SPHRAGIS_ANTHROPIC_BASE_URL=https://api.anthropic.com
export SPHRAGIS_OPENAI_BASE_URL=https://api.openai.com
./sphragis serve

Claude Code and Codex now run through the same gateway into the same audit log. The metrics confirm the split:

sphragis_requests_total{path="/v1/messages",upstream="anthropic"} 1
sphragis_requests_total{path="/v1/chat/completions",upstream="openai"} 1

Example 6: Prometheus metrics (new in v0.3.0)#

/metrics is a zero-dependency text exposition. Redaction counts are broken down by kind and direction, so you can see what the model is emitting versus what you are sending:

sphragis_redactions_total{kind="CARD",direction="request"} 1
sphragis_redactions_total{kind="CARD",direction="response"} 1
sphragis_redactions_total{kind="EMAIL",direction="request"} 1
sphragis_redactions_total{kind="EMAIL",direction="response"} 1
sphragis_redactions_total{kind="IBAN",direction="request"} 1
sphragis_audit_append_failures_total 0
sphragis_upstream_errors_total 0

sphragis_audit_append_failures_total is the one to alert on. It should always be zero; if it is not, the gateway is failing closed and dropping calls.

Example 7: Status at a glance#

sphragis status renders a cilium-style summary next to the project seal: gateway state, audit-chain health, redaction totals, and a warnings list.

        · · · ·          Sphragis  EU AI Act compliance gateway
     · ·       · ·
    ·             ·      Gateway:      running (pid 22207)
   ·               ·     Listen:       :8787
  ·     ███████     ·    Upstream:     http://127.0.0.1:9911  (override, all routes)
  ·                 ·    Audit log:    1 records, chain intact
 ·    ███████████    ·   Auto-anchor:  off
  ·   ███████████   ·    Redactors:    18 builtin, NER off
  ·                 ·    Redacted:     (total) CARD 1  EMAIL 1
   ·    ███████    ·
    ·             ·      Errors:       0
     · ·       · ·       Warnings:     1
        · · · ·            - audit log has never been anchored (sphragis anchor now)

Anchoring: Proving the Log Existed#

Anchoring proves your audit log existed at a point in time without revealing its contents. Only the opaque Merkle root leaves your network.

sphragis anchor now           # timestamp the current log's root once
sphragis anchor on 24h        # enable automatic anchoring every 24h
sphragis anchor status        # show auto-anchor state

anchor verifies the log, submits its Merkle root to public OpenTimestamps calendars, and writes a .ots proof next to the log. The proof starts pending; run ots upgrade <file>.ots later to attach the Bitcoin attestation. This is the difference between “trust me, the log is complete” and a cryptographic timestamp anyone can check.

Sphragis: Pros and Cons#

Pros#

AdvantageDescription
No SaaS in the data pathRedaction and audit are fully local; only an opaque hash ever leaves, and only if you opt in
Request and response redactionPII the model emits is stripped before it reaches your app or logs
Tamper-evident by designHash-chained log; verify names the first altered record
Claude-first, multi-providerOne gateway protects Claude Code and Codex into a single audit log
Zero-dependency observabilityPrometheus /metrics and YAML config add no new dependencies
Open core, no license keyApache 2.0; nothing in the repo is gated

Cons#

LimitationDescription
Early-stageThe core is tested and works, but it is a young project (v0.3.0)
Regex has limitsNames, addresses, and health terms need the custom-terms file or an external NER service
No official container image yetContainer image and a Kubernetes manifest are on the near-term roadmap; today you build your own or run it as a host daemon
Streaming uses regex onlyNER runs on non-streamed bodies; streamed responses use regex/custom detectors
Single-node audit logPluggable sinks and retention controls are roadmap, not shipped

When to Use Sphragis (and When Not To)#

Use it when:

  • You run AI coding agents (Claude Code, Codex) and need to know, and prove, what they send upstream
  • You operate in the EU or a regulated sector and have to keep PII out of US model providers
  • You want an audit trail that survives scrutiny without trusting a vendor’s dashboard

Reach for something else when:

  • You need full DLP across all egress, not just LLM wire formats (use a real DLP/egress proxy)
  • You need a hosted, multi-tenant control plane with SSO and RBAC today (that is the separate commercial layer, not the open core)
  • Your only concern is cost-based model routing (Sphragis routes for protocol, not price)

Deploying It#

Today the natural deployment is a host daemon. The repo ships systemd and launchd unit templates in init/:

[Service]
Type=simple
Environment=SPHRAGIS_LISTEN_ADDR=:8787
Environment=SPHRAGIS_UPSTREAM_BASE_URL=https://api.openai.com
Environment=SPHRAGIS_UPSTREAM_API_KEY=
Environment=SPHRAGIS_HOME=/var/lib/sphragis
ExecStart=/usr/local/bin/sphragis serve
Restart=on-failure
DynamicUser=yes
ProtectSystem=strict
NoNewPrivileges=yes

For Kubernetes, the intended shape is a sidecar in the pod that runs your agent or app: point the app’s ANTHROPIC_BASE_URL/OPENAI_BASE_URL at http://localhost:8787 and mount the audit log on a volume you ship to durable storage. An official container image and a reference manifest are on the roadmap; until they land, the binary is a static Go build, so a minimal multi-stage image is all it takes.

Troubleshooting#

IssueSymptomResolution
Claude Code auth fails401 from Anthropic through the gatewayUpgrade to v0.3.0; earlier builds only forwarded Authorization, not x-api-key/anthropic-version
Calls silently droppedsphragis_audit_append_failures_total climbingThe gateway is failing closed on audit write errors; check disk and SPHRAGIS_HOME permissions
Names not redactedPerson names pass throughRegex cannot catch arbitrary names; add SPHRAGIS_CUSTOM_TERMS_FILE or point SPHRAGIS_NER_URL at a NER service
verify fails after a crashLast record truncatedA partial final line means the process died mid-append; trim the incomplete record and re-verify
reveal returns nothingTokens unchangedNo vault key set, so no originals were stored; reversibility requires SPHRAGIS_VAULT_KEY

Configuration#

Everything is environment variables (or an optional YAML file at SPHRAGIS_CONFIG, precedence env > file > default). The knobs you will actually touch:

Env varPurpose
SPHRAGIS_ANTHROPIC_BASE_URL / SPHRAGIS_OPENAI_BASE_URLPer-protocol upstreams (auto-routing)
SPHRAGIS_UPSTREAM_BASE_URLOverride all routes to a single upstream
SPHRAGIS_VAULT_KEY / SPHRAGIS_VAULT_KEYFILEEnable reversible tokenization (base64 32-byte key)
SPHRAGIS_CUSTOM_TERMS_FILE / SPHRAGIS_NER_URLExtra term list / external NER for names and addresses

The full list (listen address, audit log path, OTS calendars, and the rest) is in the README.

Try It#

Sphragis is open source under Apache 2.0 and built in the open. The repo has everything: the gateway, the test suite, governance files, and the roadmap toward a CNCF Sandbox submission.

Install in one line:

# Homebrew (macOS)
brew install --cask sphragis-oss/sphragis/sphragis

# or prebuilt binary (macOS / Linux)
curl -fsSL https://raw.githubusercontent.com/sphragis-oss/sphragis/main/install.sh | bash

Then point Claude Code at it and watch the audit log fill up:

export ANTHROPIC_BASE_URL=http://localhost:8787
sphragis start && sphragis status

Conclusion#

The compliance story around LLMs has been stuck between two bad options: trust a SaaS scrubber with your prompts, or bolt together regex in every app and hope nobody asks for an audit. Sphragis takes the third path. Everything that matters, redaction of both requests and responses, the tamper-evident log, the reversible vault, happens inside your own trust boundary, and the only thing that can ever leave is an opaque hash you choose to anchor.

v0.3.0 is the release where it became genuinely useful day to day:

  • Response redaction closes the loop, so the model cannot leak PII back into your logs
  • Reversible tokenization gives you a key-gated escape hatch without storing originals by default
  • Multi-provider routing means one gateway covers Claude Code and Codex at once
  • Prometheus metrics and a YAML config make it operable, not just runnable

If you run AI agents in a regulated environment, or you just want to stop guessing what kubectl-on-an-LLM is shipping off to a third party, clone the repo and put it in front of one client for an afternoon. The audit log will tell you things about your prompts you did not know. Contributions, issues, and design feedback are all welcome, the goal is a community-governed, vendor-neutral project.


If you found this useful, you might also enjoy my related posts on AI and security in the cluster:

Sphragis EU AI Act compliance gateway

EOF · 17 min · 3410 words
$ continue exploring
Building a Read-Only Kubernetes Agent with Google ADK (Go) // How to build a safe, read-only Kubernetes operations assistant in Go using Google's Agent Development Kit, packaged in a scratch image and deployed via Helm with External Secrets Operator. #sre #kubernetes #golang
// author
Nikos Nikolakakis
Nikos Nikolakakis Principal SRE & Platform Engineer // Writing about Kubernetes, SRE practices, and cloud-native infrastructure
$ exit logout connection closed. cd ~/home ↵
ESC
Type to search...