I self-host a lot of stuff these days. This blog is self-hosted for more than a decade, but I also run my own mail server since I think 2014, as well as various other small websites, a RSS aggregator, CalDAV synchronization, the Immich photo storage app, and whatnot.
The list is growing longer every year, and so is the attack surface. So I spent a bit of time to reduce the attack surface as far as it is easily possible.

Personal tools like RSS aggregator and CalDAV
These are only ever accessed by me (or my wife). Thus they require mTLS now, i.e the client needs to provide a TLS client certificate or the web server will refuse the connection.
The big advantage of mTLS is that no requests reach the hosted applications unless the client is already authenticated, so if they have e.g. bugs in their login mechanism, that doesn’t matter any more. It truly reduces attack surface. Brute-force attacks on credentials are also a solved problem.
mTLS setup: key data
Using mTLS is in principle simple but has a few stumbling blocks. In principle, on the server simply generate a new self-signed CA:
openssl ecparam -genkey -name secp256k1 -noout -out ca.key
openssl req -new -x509 -days 100000 -key ca.key -out ca.crt
On the client, generate a key and a CSR:
openssl ecparam -genkey -name secp256r1 -noout -out client-sven.key
openssl req -new -key client-sven.key -out client-sven.csr
Copy the CSR to the server and sign it, then copy the certificate back:
openssl x509 -req -in /tmp/client-sven.csr -CA ca.crt -CAkey ca.key -out /tmp/client-sven.cert -CAcreateserial -days 10000 -sha256
Pack the client’s key and certificate into a p12 archive:
openssl pkcs12 -export -macalg sha1 -legacy -out client-sven-legacy.p12 -inkey client-sven.key -in client-sven.cert
The legacy stuff here is needed for older android phones, which do not support SHA256 or whatnot and just say “wrong password”. The same goes for some choices of elliptic curve, if you import the p12 file you just get “wrong password”.
You can now copy this p12 archive to your andoid phone and import it into the cert store, and import it in your browser. Browsers will ask you for the certificate and you can select it when you browse to a protected site, and various android apps support selecting a certificate in their login config.
In my opinion for this “family” type use case, there is no reason to create multiple client certificates, it just complicates everything and provides basically no tangible benefits. If you want, it would require no changes to the setup though.
mTLS setup: httpd
Now, in your httpd.conf or so, you can simply point httpd to the CA file:
SSLCACertificateFile "/etc/httpd/ca/ca.crt"
SSLVerifyDepth 1
And now you can put
SSLVerifyClient require
into whatver <VirtualHost> directive you have to enable mTLS for that virtual host.
A small confusion: directory-specific mTLS
httpd does support SSLVerifyClient require for directories, either in its config or even in .htaccess files. However, in practice, this does not work. The reason is that doing this requires TLS renegotiation after the client has sent the path it wants to access as part of the initial session. That apparently used to be a thing ten years ago, but from what I have understood browsers have since dropped the feature because it was complicated to do for HTTP/2. Chrome doesn’t have it at all, and Firefox has it but it’s behind a config flag and I couldn’t get it to work, either.
So, unfortunately, that’s not a thing.
The more difficult case: mixed-access services like Immich
The photo management tool Immich now uses the same approach (except that that is in addition proxied to a box at my home because storage is expensive to rent in a data center and it needs a lot of RAM sometimes). Here, the problem is that you can create share links for albums and pictures, which are supposed to be public.
The solution here is to use a small proxy application which has much smaller attack surface to only serve the share links: https://github.com/alangrainger/immich-public-proxy This works nicely and isn’t hard to set up.

And what about WordPress?
WordPress (i.e. this blog) is another security disaster waiting to happen, so it would be good to reduce attack surface here a bit as well. My trust in WordPress not having undiscovered RCE bugs exploitable without auth is moderate at best. So I would feel better with some access control which goes beyond the usual eternally-repeated recommendations of keeping things up to date, using long passwords and checking file permissions.
To reduce attack surface, the most obvious thing I think I can do is limiting access to the admin interface and login form to known users (without having WordPress itself decide who is known and who isn’t). This is even recommended in many guides, but they usually limit by source IP which is not feasible for most ISPs. mTLS is not an option either, since WordPress needs public access and, as described above, per-directory mTLS is not really possible.
So I came up with the following contraption: I limit access to wp-login.php and the wp-admin folder to localhost via .htaccess:
Require ip 127.0.0.1
This makes the login form and admin interface, two big attack targets, only accessible from the server machine itself. The idea now is to use ssh to port-forward the server’s local access to the software to my notebook using ssh -L, and then edit posts on localhost.
Unfortunately it’s not quite that simple because 1) the server hosts multiple virtual hosts differentiated by ServerName which gets lost with this trick, and 2) WordPress uses absolute paths with the domain in it everywhere.
2) is easy to fix at least, in wp-config.php put
define('WP_HOME', '/');
define('WP_SITEURL', '/');
I don’t love it, but it’s an acceptable price to pay.
For 1), we need to get a little more crafty. We cannot differentiate by host name that the ssh-forwarded traffic should go to the blog.svenbrauch.de subdomain. But we can do it by port:
Listen 127.0.0.1:8081 http
<VirtualHost _default_:8081>
ServerName localhost
DocumentRoot /path/to/wordpress
... all the other stuff you might need
</VirtualHost>
With the new Listen directive, we make httpd open port 8081 as well. Since we only need it on localhost, no need to make it public. Then we create a copy of the VirtualHost entry for wordpress and catch-all redirect all traffic arriving on port 8081 there.
And now, you can simply
ssh -L 8081:localhost:8081 myserver
and then point your browser to http://localhost:8081/wp-login.php and log in normally and everything should work.

Postfix and Dovecot
We can also require the client to present a certificate when submitting mail via SMTP or fetching mail via IMAP.
For postfix, we can do this: if you have disabled submission via unencrypted SMTP (port 25) and SMTPS (port 465) by adding
...
-o smtpd_sasl_auth_enable=no
then, simply add to the submission service in master.cf:
... -o smtpd_tls_req_ccert=yes
-o smtpd_tls_CAfile=/etc/httpd/ca/ca.crt
That should already do. Just make sure your client submits mail using the submission port 587 with TLS (not STARTTLS).
For dovecot (IMAP), it’s a little more annoying. First, it requires you to have a Certificate Revocation List (CRL) (for your CA which has 1 certificate generated). And generating that is apparently not something people commonly do just like that. However, you can kind-of push through it with this hack:
touch /etc/ssl/index.txt
echo 1001 > /etc/ssl/crlnumber
openssl ca -gencrl -crldays 10000 -out test.crl -keyfile ca.key -cert ca.crt
I think you can delete the two helper text files afterwards. IIUC the proper way to do it is to have a CA config file which places these into some local dir. But whatever.
Then concat the CA cert and the CRL into the form dovecot wants:
cat ca.crt test.crl > ca.pem
Now, in your dovecot.conf, enable mTLS:
ssl_server_ca_file = /etc/httpd/ca/ca.pem
ssl_server_request_client_cert = yes
auth_ssl_require_client_cert = yes
Now, if you, like me, use SASL auth which makes postfix query dovecot for credentials, your mail submission will not work any more because postfix cannot connect to dovecot because dovecot now requires postfix to present a certificate which it doesn’t do.
Fortunately, there’s a way to work around this: add
protocol smtp {
auth_ssl_require_client_cert = no
}
Notably, the inverse like in the documentation, which suggests
protocol !smtp {
auth_ssl_require_client_cert = yes
}
did not work for me. But maybe I did it wrong somehow.
Anyways, with all that in place, it works! You should now also probably disable IMAP without TLS which is also a bit weird because you do it by setting the port to 0:
service imap-login {
inet_listener imap {
port = 0
}
}
- Fourier Transform Spectroscopy continued, or: How to reproduce NIST ASD results to 8+ digits at home
Categories: Everything
Great write-up. Using mTLS for the family-only services is such a clean way to take the whole login surface off the table, and the note about the legacy p12 export for older Android phones would have saved me an evening of ‘wrong password’ errors. Good to know about per-directory SSLVerifyClient failing because of renegotiation, too. Does the Immich mobile app handle the client certificate reliably for background uploads, or did you have to make an exception there?
Thanks! For me, Immich upload works fine after configuring the certificate in the app, I didn’t need to do anything else. I did read some bug reports about some quirks with client certificates because it seems some Android APIs don’t pass it through correctly, but so far I didn’t notice any problems whatsoever.