What we do.

A structured internal review against an established checklist, every quarter, plus immediately after any non-trivial dependency bump or auth-surface change. The checklist follows the OWASP ASVS Level 2 + OWASP API Security Top 10 framing — we don't claim certification, but we use the framework's questions verbatim where useful. Each pass produces a dated report committed to the repository, evidenced by file paths and test files, with findings either remediated in a referenced commit or accepted as residual risk with rationale.

  • Master checklist — eight scope areas: authentication and session, public viewer + JSON-LD endpoint, dashboard write paths, storage, custom domains, dependencies, secrets, logging and monitoring.
  • Quarterly review — every row evidenced in line with file paths and test files; pass / partial / fail call per row. Findings either remediated in a referenced commit or accepted as residual risk with explicit rationale.
  • Dependency hygienepip-audit on every review pass. Pinned requirements.txt with no version ranges; bumps are deliberate and tested.

The load-bearing controls.

Concrete, verifiable claims — every one of these is a property of code or configuration a reviewer can check.

  • Encrypted in transit. TLS on every public surface (qrregistry.io, qrregistry.eu, custom-domain viewers) via Caddy with on-demand certificate issuance. The session cookie is HttpOnly + Secure + SameSite=Lax.
  • Encrypted at rest. PostgreSQL + MinIO on EU hardware with disk-level encryption.
  • No passwords. Magic-link only. The raw token is ~256 bits of CSPRNG entropy, expires in 15 minutes, and is consumed on first use; the database stores only a SHA-256 hash, so a database read on its own can't sign anyone in.
  • Differentiated audience auth. Auditor / recycler / repairer / notified-body credentials are short-lived JWTs revocable from a single admin surface; revocation takes effect on the next request, not at JWT TTL expiry.
  • Ownership-checked write paths. Every passport, attestation, domain, and asset write is gated server-side on row.user_id == user.id. Other users' rows return 404 rather than 403 — we don't leak existence.
  • Restricted-tier filter. A single function (strip_to_visible) gates every passport surface — HTML viewer, JSON-LD endpoint, audit feed, PDF. No field can leak through one branch and not the others.
  • Append-only audit chain. Every save writes a passport_revisions row with hash_before / hash_after / structured diff. DELETE /dashboard/passports/{id} returns 403 unconditionally — Article 10(4) lifetime persistence.
  • Custom-domain allowlist. Caddy's on-demand TLS ask-endpoint is bound to loopback and consults the verified-domains table; only verified-and-provisioned hostnames issue a certificate. Single-claim invariant — a hostname can only ever belong to one owner.
  • Secrets discipline. No secrets in source. .env is the only inventory; per-service env vars (database, Stripe, Resend, MinIO) are independent.
  • Privacy-preserving analytics. Scan events record an ISO country code (header-derived, never raw IP) and a coarse device class — never a tracking cookie, never a credential identity. Documented and surfaced under /privacy.

What we explicitly don't do.

We don't pay for a third-party penetration test until a customer's procurement gate makes it justified cost. ESPR Article 11(h) doesn't define a certification path or mandate third-party testing; commissioning a €15-30k engagement buys credibility but no regulatory <code>✅</code>, and the dated internal review documents the same evidence at a lower cost while the company is small.

We don't claim SOC 2 or ISO 27001 certification. Our infrastructure provider's posture is what's behind the <em>“ISO 27001 aligned”</em> footer line; the certification belongs to them, not to Contenza K/S.

We don't pretend a clean review is the absence of findings. The 2026-04-30 review surfaces five 🟡 partial rows — four are dependency CVEs with available fix versions tracked as a follow-up bump, and one is a deferred application-layer rate-limit. Honest is more useful than aspirational.

What you can read.

  • The master checklist lives at docs/security-review-checklist.md in the source repository — every row is a question the next review must answer with evidence.
  • Every dated review pass lives at docs/security-review-{YYYY-MM-DD}.md — file paths, test files, and findings, in line. The git history shows what changed between passes.
  • Every public claim on this page maps to a row in the most recent review.

Reporting a vulnerability.

Please email the team with details. We treat security reports as priority and respond within one business day. There is no formal bug bounty programme yet — at our scale, recognition is a heartfelt thank-you and a place in the changelog.

Questions on this policy? Use the contact form — or email the team through the details on the contact page.