How we got SSLBoard to 99/100
A while back I wrote up the five Cloudflare settings that get most domains into good shape: Cloudflare SSL/TLS hardening. HTTPS redirects, HSTS, a TLS 1.2 floor, TLS 1.3, and Advanced Certificate Manager for the cipher list. That post still holds up. If you do nothing else, do those.
But it won’t take you to 100. It got SSLBoard’s own domain to about 92, and the gap from 92 to a near-perfect score is a different kind of work. The easy wins are toggles in a dashboard. The last few points are a grind across every service you run, and they punish you for the one host you forgot about.
This is what closing that gap actually looked like for us. We landed on 99. I’ll be honest about the one point we can’t get, and why.
CSP across all 25 endpoints
SSLBoard isn’t one box. It’s a frontend, an API, a scanner, a few internal services, and a handful of supporting hosts. Twenty-five endpoints in total once you count everything that answers on 443. Our Web Hardening score was being dragged down because most of them had no Content-Security-Policy header at all.
CSP is one of those headers that’s trivial on a single service and miserable across an estate. Every host needs a policy, and the right policy depends on what the host does. A JSON API should be locked down to almost nothing. A frontend that loads fonts and images and talks to an API needs a more permissive one. There’s no single header you can paste everywhere and call it done.
For the pure API services, the policy is about as tight as it gets. Nothing loads resources, nothing should ever be framed:
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
default-src 'none' means the browser won’t fetch a single thing this policy didn’t explicitly allow, and since an API returns JSON, there’s nothing to allow. frame-ancestors 'none' does the job that X-Frame-Options: DENY used to do, and we set both because some scanners and older browsers still look for the latter.
The annoying part wasn’t writing the policy. It was making sure all 25 services actually shipped it, with the right variant, and kept shipping it after the next deploy. SSLBoard checks CSP on every hostname separately, so 24 perfect services and one that regressed still shows up as a problem. That’s the point of scanning your whole estate instead of one flagship domain. The flagship is never the thing that breaks.
Picking the exact ciphers on Cloudflare
The Cloudflare post covers this, but it’s worth repeating because it was the single biggest jump we got. Cloudflare’s default cipher list still negotiates CBC suites:
TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
CBC mode has a long history of padding oracle and timing attacks, and these particular suites also use SHA-1 for the MAC. AEAD suites like AES-GCM and ChaCha20-Poly1305 don’t carry that baggage.
On Cloudflare’s free and Pro plans you can’t touch the cipher list. To restrict it you need Advanced Certificate Manager, which is $10/month and unlocks per-hostname cipher configuration. We bought it, dropped the CBC suites, and the Ciphers category went from a 62 to full marks on the hosts behind Cloudflare. Worth ten dollars.
Picking the exact ciphers on our Go servers
Not everything we run sits behind Cloudflare. The PQC test server terminates TLS itself, and Go’s defaults are reasonable but not exactly what we wanted. The fix is to set CipherSuites explicitly so you control the order and drop anything you don’t like. This is the actual config from that server:
// sslboardCipherSuites is the TLS 1.2 cipher order we use across our
// edge services. TLS 1.3 suites (0x1301-0x1303) are negotiated
// automatically and can't be configured here, which is fine.
var sslboardCipherSuites = []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, // 0xc02b
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, // 0xcca9
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, // 0xc02f
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, // 0xcca8
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, // 0xc02c
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, // 0xc030
}
func buildTLSConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS13,
CipherSuites: sslboardCipherSuites,
PreferServerCipherSuites: true,
}
}
A few things worth knowing if you copy this. CipherSuites only applies to TLS 1.2. Go negotiates the 1.3 suites on its own and gives you no knob for them, which is deliberate on the Go team’s part because the 1.3 suites are all AEAD anyway. PreferServerCipherSuites tells the server to pick from its own ordered list rather than honoring the client’s preference, so the order above is the order clients get. And note there are no CBC entries at all. That’s the whole point.
One footgun: a couple of suites you might expect, like ECDHE-RSA-AES256-SHA384 (0xc028), simply aren’t implemented in crypto/tls. If you try to reference a constant that doesn’t exist, it won’t compile, and if you go looking for it by ID you’ll come up empty. Don’t waste an afternoon on it like I did. Stick to the GCM and ChaCha20 suites the standard library actually ships.
CAA records
CAA records tell the world which certificate authorities are allowed to issue for your domain. Without them, any CA on earth can issue a cert for you, and you’d never know until it showed up in a Certificate Transparency log.
I’d expected this to be tedious. It wasn’t, because Cloudflare does most of it for you. I added a single CAA record for our zone in the Cloudflare dashboard, and Cloudflare noticed and filled in the rest, adding records for the CAs it uses to issue certificates on your behalf. One manual record turned into a complete set.
You can check what a zone publishes with dig. Here’s SSLBoard’s:
$ dig CAA sslboard.com +short
0 issue "letsencrypt.org"
0 issue "pki.goog; cansignhttpexchanges=yes"
0 issue "ssl.com"
0 issue "google.com"
0 issuewild "letsencrypt.org"
0 issuewild "pki.goog; cansignhttpexchanges=yes"
0 issuewild "ssl.com"
0 iodef "mailto:security@sslboard.com"
The issue lines list CAs allowed to issue normal certificates, issuewild covers wildcards, and iodef is where a CA should report a policy violation. The pki.goog and google.com entries are Google Trust Services, which is one of the CAs Cloudflare issues through, so those landed automatically when I added the first record.
If you want to see the records with their TTLs rather than just the values, drop +short:
$ dig CAA sslboard.com +noall +answer
sslboard.com. 300 IN CAA 0 issue "letsencrypt.org"
sslboard.com. 300 IN CAA 0 issue "pki.goog; cansignhttpexchanges=yes"
sslboard.com. 300 IN CAA 0 issue "ssl.com"
sslboard.com. 300 IN CAA 0 issuewild "letsencrypt.org"
If you run a domain with a lot of subdomains, this gets harder fast, because CAA inherits down the DNS tree and a record on a parent can quietly block a child. We built the CAA Policy Explorer in SSLBoard for exactly that, so you can see the effective policy across every subdomain at once instead of running dig a hundred times.
The one point we can’t get
So that’s CSP everywhere, AEAD-only ciphers on both Cloudflare and our own Go servers, and CAA records. Add it up and SSLBoard scores itself a 99.
The missing point is on the frontend, and it comes down to Astro. The site is built with Astro, which inlines small chunks of JavaScript directly into the HTML for performance. Inline scripts are great for load times and a problem for CSP, because the strict way to allow them is script-src 'unsafe-inline', and the name is a fair warning. Allowing arbitrary inline script is exactly the hole CSP exists to close. A nonce or hash based policy is the proper fix, but with Astro’s build output that’s more involved than it sounds, and we haven’t shipped it yet.
So we sit at 99 on purpose, for now. I’d rather be honest about a real unsafe-inline on one host than paper over it and pretend the frontend is something it isn’t. The nonce work is on the list. When it lands, I’ll update this post.
If you want to see where your own domain stands, run an SSLBoard report. The whole reason we put SSLBoard through this is that we look at the same scorecard we hand everyone else.