How we reached 100% on internet.nl — and how to verify it yourself
Our site scores 100% on the public internet.nl test: IPv6, modern TLS, RPKI and a strict CSP without unsafe-inline on a static site. Here is the real journey — outage included — and the commands to verify every claim.
Also available in: Français
A firm that advises Swiss companies on AI and security, running a sloppily configured website of its own: that is the blacksmith's house with rusty door handles. So we took our own medicine. The result: internet.nl, the public test backed by the Dutch government and a coalition of standardisation bodies, gives heldevia.ch 100%.
This article tells the real story — including the outage we caused ourselves — and, true to our principle, every claim ships with the command to verify it. Don't take our word for anything; that is rather the point.
The starting point: 70%
Our stack is deliberately sober: a static site (Next.js export) served by nginx, behind a Traefik reverse proxy, on a small ARM VM at Oracle Cloud in Zurich. On the first scan the substance already passed — DNSSEC signed, HTTPS everywhere, a full set of security headers. The score: 70%.
What was missing came down to four work streams: no IPv6, TLS with leftovers
from the 2010s, a Content Security Policy carrying unsafe-inline, and an
IPv4 prefix without RPKI validation. None of them cosmetic.
IPv6: the "easy" one that wasn't
Enabling dual-stack on the cloud side was the clean part: a /56 prefix on the virtual network, a /64 on the subnet, an address on the VM — all in Terraform. Docker already published its ports on IPv6, Traefik already listened on both families. And yet: consistent timeouts over v6.
The culprit was the host firewall, and the reason is instructive. Over IPv4,
traffic enters through a DNAT rule and traverses the FORWARD chain — it
bypasses the host firewall. Over IPv6, without ip6tables configured, Docker
falls back to a userland proxy: traffic arrives through the INPUT chain,
where ports 80 and 443 were closed. Same server, two completely different
network paths depending on the address family.
TLS: three layers of old to strip
The scanner flagged three distinct problems, in increasing order of difficulty.
The cipher suites. The proxy still offered CBC suites with SHA-1. Traefik lets you define TLS profiles per hostname through its file provider: we applied a modern profile — TLS 1.2 minimum, AEAD suites only — without touching the other services on the machine.
The certificates. Let's Encrypt was issuing RSA-2048, which the test classes as "phase out". One elliptic-curve ACME resolver later, the chain is ECDSA P-256 signed with SHA-256.
The handshake signature — the trap. The trickiest one: even with modern suites and an ECDSA certificate, internet.nl still reported SHA-1. It was neither the suites nor the certificate, but the key-exchange signature in TLS 1.2: Go (which Traefik inherits) would still sign with SHA-1 when a client offered nothing better, and exposes no setting to forbid it. The fix was not configuration but an upgrade: Go 1.25 removed SHA-1 from the TLS 1.2 handshake per RFC 9155, and Traefik v3.6 ships that runtime. Proxy upgraded, problem dissolved.
A calibration lesson along the way: our first reflex — going TLS 1.3-only — made the SHA-1 disappear but cost the A+ at SSL Labs, which requires TLS 1.2 for its top grade. Two serious scanners, two definitions of "good". The upgrade fix satisfies both.
A strict CSP on a static site: the wall, then the door
internet.nl's last warning on headers: our CSP contained unsafe-inline.
That is the directive that undoes most of what a CSP protects against — it
allows exactly what an injection would use.
We measured the wall first. A static Next.js export contains inline hydration
scripts that change with every build — 226 distinct blocks in our case.
Hashable, therefore manageable. The real wall: our animation library generated
2,430 style="" attributes in the exported HTML, and style attributes can
be allowed neither by hash nor by nonce. No configuration works around that;
the site had to change, not the header.
So we removed the animation library (two components rewritten in pure CSS, same motion), eliminated the remaining inline styles, and replaced the generated 404 with a static page. Then we wrote a small generator that, on every deployment, hashes each inline script of each exported page and produces a per-page policy for nginx.
Two lessons paid for in cash, in the transparent spirit of this series:
- nginx truncates configuration tokens at 4,096 bytes. Our first version — a single global policy of 12 KB carrying every hash — did not parse and took the site down for four minutes. Hence the per-page policy (~1.1 KB at most), and a configuration test against the production nginx image before any deployment.
- The guardrail must break the deployment. The generator refuses to
publish if a single
style=""attribute reappears in the export. A carelessly added component cannot erode the policy silently.
A welcome side effect: 32 KB less JavaScript per page. And what you are reading right now obeys the same rule — the syntax highlighting in these code blocks is CSS classes, not inline styles.
RPKI: fishing for an IP address
RPKI lets networks cryptographically verify that a route announcement is legitimate — protection against BGP hijacks. Our IPv6 was already covered by an Oracle ROA. Our IPv4, however, sat in one of the only two ROA-less prefixes among the eight the cloud assigns to virtual machines in the Zurich region.
A ROA is signed by the prefix holder: the hosting provider, not the customer. We could not sign it ourselves, and waiting for Oracle to sign that specific prefix was out of our hands. Our unapologetic solution: fishing. Reserve a fixed IP address, check which prefix it lands in, return it if the prefix is unsigned, repeat. First cast: an address in a prefix with a valid ROA. Switching the old address for the new one took seven seconds of IPv4 downtime — dual-stack clients noticed nothing, IPv6 never moved.
The finishing touches: signed security.txt and HSTS preload
Two final standards complete the picture. A PGP-signed security.txt
(RFC 9116) tells security researchers where to write to us — with the key
published on the site. A gotcha we lived through: the reference validator
decodes signed files as Latin-1; a single em dash in a comment makes it blame,
wrongly, the PGP block. Our file is pure ASCII.
Finally, HSTS with preload: the domain is submitted to the browsers'
inclusion list, so they will enforce HTTPS before the very first visit.
What we would do differently
The honesty of a forge journal is also the list of scars:
- Scan first, believe second. We thought our TLS was clean because our configuration was. The SHA-1 lived two layers below, in the proxy's runtime. An external scan before starting would have saved one false assumption.
- Test configuration with the production artefact. The four-minute outage
came from an
nginx -trun with a different nginx version than the one serving the site. The validation test now uses the exact production image. - Measure three times. Local performance scores vary by ±8 points from run to run. Every decision is now taken on the median of three measurements — otherwise you are optimising noise.
The final score fits in one line: 100% on internet.nl, A+ at SSL Labs on IPv4 and IPv6, zero CSP violations — on infrastructure that costs zero francs a month. Rigour is not a matter of budget; it is the same rigour we apply to the AI systems we build for our clients. The details of our posture, claim by claim, remain public on our Security page.
Frequently asked questions
- Is a 100% score on internet.nl essential?
- No. internet.nl measures adoption of modern standards (IPv6, DNSSEC, TLS, security headers, RPKI), not an organisation's overall security. But every missing point deserves a conversation: some are defence in depth, others are about availability and routing hygiene. For us, aiming at 100% was a demonstration of method — every claim on our Security page has to be verifiable.
- Is this score achievable for any site?
- The strict 100% is considerably harder with a CMS or dynamic rendering: a hash-based CSP requires HTML that is frozen at build time. With request-time content you need server-side nonces and strict discipline around inline styles. A static site shrinks the attack surface and makes this level of rigour realistic — which is precisely why ours is static.
- How long does it take?
- In our case, two days of focused work — because the infrastructure was already described in Terraform, the site is static and we control the reverse proxy. Without infrastructure as code, budget more: half the work is understanding exactly what your hosting provider does on your behalf.
- Why does RPKI depend on your hosting provider and not on you?
- ROAs (Route Origin Authorizations) are signed by the holder of the IP prefix — your carrier or your cloud, not you. If your address sits in a prefix without a ROA, your options are: ask the provider to sign it, or move to an address in a prefix that is already signed, as we did.