There are some services that I expose to the internet (using Apache reverse proxy) that really should be accessed by only a small set of devices. Requiring client certificates seems like a great way to reduce the attack surface and prevent brute force attacks (since the attacker doesn’t even get a chance to attempt a login).
I wonder about the difficulty on the client side as well as other practical implications. The clients are smartphones of various makes.
It’s very easy to make a custom CA and issue certs. Here’s a good tutorial.
Unfortunately in practice It depends greatly on what’s on the other side (the client app). Some examples:
- DAVx5 on Android works perfectly fine and uses the client cert from the system store. 10/10, this is how all apps should work.
- Ntfy on Android works perfectly fine but wants the client cert file loaded in the app, it doesn’t use the one loaded in the system store. This sucks because instead of loading a cert into the system store once and then deleting it you have to keep the cert file around for this kind of apps, in Android shared storage, which is accessible to all apps.
- Same for Immich, wants the cert loaded in the app. Also, it will randomly lose it (on both iOS and Android). Yes, you heard that right. So it’s basically useless and I had to resort instead to a key in a custom HTTP header; which isn’t exactly the same as mTLS, but helps secure the service at reverse proxy level so it’s better than nothing.
- Firefox on Android will use the cert from the system store, and then it will crash. Again, useless.
Oh did I mention how you get a mTLS client cert to an app on an iOS device? You send it over email to an account that the device has access to through the Mail app, then share the attachment. Yep.
It’s also not exactly straightforward to use mTLS with reverse proxies.
Let’s take for example Caddy and say you want unconditional mTLS for all reverse proxy hosts. Easy enough:
tls /path/to/domain-cert/fullchain.pem /path/to/domain-cert/privkey.pem { client_auth { mode required trust_pool file /path/to/custom/ca.pem } }But suppose you don’t want unconditional mTLS, you’d like to let clients in if they have mTLS or a custom header, or do different things depending if the client has valid mTLS or not. Does Caddy offer a built-in conditional to act on mTLS status? Nope!
As a workaround I’m setting the client_auth mode to
verify_if_givenand then using a DIY conditional that checks if the variablehttp.request.tls.client.certificate_der_base64is empty or not. But it’s undocumented so who knows if it may break at any point.For reference, how you handle both custom headers and mTLS at once (after setting the mode as I’ve mentioned):
@immich host "whatever.example.com" handle @immich { @not_authorized { not header X-Custom-Pass "LONGRANDOMKEY01" # jim not header X-Custom-Pass "LONGRANDOMKEY02" # bob vars_regexp {http.request.tls.client.certificate_der_base64} ^$ } error @not_authorized 403 reverse_proxy http://immich.lan:port }The nested “not not” is required because Caddy can only do logical AND in group conditionals, so to do logical OR you basically have to do NOT (NOT a AND NOT b).
It can be a pain to manage but it works well once it is setup
Just make sure it isn’t your only line of defense
I use it for Home Assistant and ntfy in combination with Caddy. Certificates are managed by Vaultls (https://github.com/7ritn/VaulTLS) which makes it quite easy to setup and use on new devices.
I use Tailscale for this, with my own Headscale server so that I am in control of everything. It’s a much easier approach that is more likely to pass the spousal acceptance factor.
This. Tailscale just works and has functioning VPN on demand for your networks.
I’m already running Headscale, and it works great. But to expose individual services to individual devices it feels like an overkill. I don’t actually need all these devices to connect to the tailnet all the time, and some of these devices I don’t even want to be able to access the entire tailnet.
I recommend taking a look at the new Tailscale access controls > policies (aka “grants”). Much easier to understand than their old ACLs. You can quickly draw up rules that only let specific devices access specific nodes and even only specific ports.
There’s one small potential point of confusion, in that you can’t use node names directly in the rules. You have to go to access controls > definitions > hosts and make up a name there assigned to the node IP address, and then you can use that name in a policy.
In other words, even if you already have a tailnode called “nas” with a fixed IP, you can’t just say “nas” in a policy. You have to go to hosts, define one called “nas” that points to that tailnode’s IP, and then you can use “nas” in the policy… 🤪
I understand the logic, which is that hosts and definitions in general are much more powerful and can define IP netmasks and IP groups and then you can use those groups in policies… but boy, the redundancy when you have to do this for single nodes that are already assigned a name and an IP is rubbing me wrong.
MTLS is great protection, but the use case must support it. For example, if you want your app to support registration for new accounts or if you have a lot of people you want to have access, that might make mTLS unwieldy to manage.
I host apps behind Traefik reverse proxy, using the d.rymcg.tech framework. It make it easy to protect your apps behind HTTP basic auth, Oauth2, or mTLS(or any combination).
Ah, thanks for the idea. I’ll look into Traefik, I’ve heard of it, but wasn’t sure what it could do.
I use mTLS with Caddy to expose some of my services. It is quite manageable, and I only use if for myself and my wife.
We both use Android phones, and I configure access via apps for the services, that use the devices’ certificate store. It seems like the iOS way is to provide the client certificate and password to each app that’s gonna use it. I’m happy we can avoid that.
Big plus side for us is the simplicity of it all, there is no “always on VPN” requirement, and things “just work” with acceptable security.
I also sometimes access the services via Firefox, which is also able to use the device’s certificate store, although I have to keep selecting the same certificate each time.
mTLS can be a very good extra gate for a small, controlled device set. But “some services”, “smartphones” and “Apache reverse proxy” is not enough to recommend it responsibly.
The decisive questions are:
- Which services are we talking about: a normal web UI in a mobile browser, native apps, APIs, WebDAV, SSH-like administration, something else?
- Are those personally managed devices, or devices belonging to multiple users?
- iOS, Android, both — and which browsers/apps?
- Is there MDM, or would certificate enrollment, replacement, revocation and renewal all be manual?
- What happens when a phone is lost, reset, sold, or its private key leaks?
- Does each device get its own certificate, or would the same
.p12be copied around? (Please do not do the latter.) - Is the real goal “no public login page”, “device authentication”, or simply private remote access?
For a handful of your own devices, per-device mTLS certificates can be perfectly reasonable. For a mixed fleet of smartphones without MDM, the operational overhead is often the actual attack surface: secure initial delivery of the PKCS#12 bundle, private-key protection, expiration, rotation, revocation, backups, and users selecting the right certificate.
Also: mTLS is a gate, not a replacement for normal authentication and authorization. I would usually still keep the application login, use individual device certificates with a private CA, short-ish validity, documented revocation, and rate limits. Otherwise you have merely replaced password brute force with “whoever extracted or copied the client private key gets through.”
Depending on the service, a WireGuard/Tailscale-style private network or an identity-aware proxy may be less painful on phones — but that is impossible to judge without knowing the actual services and client workflow. Certificate-based mobile access is feasible, yet the setup and lifecycle vary materially across platforms and applications.
So yes: the missing basics are not a minor omission; they are the question.
My thinking is to put Immich, Matrix, and CalDAV/CardDAV behind mTLS. So the clients practically do connect via native mobile apps rather than a browser. The devices belong to a small number of users, I don’t manage them, but can distribute the keystores, and plan on doing the PKI manually as it’s really not a lot to keep track of.
Not an authentication replacement for sure, just an extra layer of protection. The goal is mostly so that if there’s a new critical exploit, I don’t have to drop everything I’m doing and immediately mitigate.
I just saw your other reply on Lemmy regarding Headscale.
Headscale should already solve that use case without putting mTLS in front of every service.
By default, a Headscale tailnet is rather permissive, but you can load a policy and use Grants (or ACLs, though Grants are the preferred direction) to explicitly allow only the connections you want. A phone does not need access to “the whole tailnet” just because it is enrolled.
For example, each user/device group could be allowed to reach only:
- the Immich host on its HTTPS port;
- the CalDAV/CardDAV host on 443;
- optionally the Matrix client endpoint on 443;
…and nothing else. No SSH, no databases, no admin UIs, no access to other tailnet nodes. Start with an empty
grantspolicy — which denies all tailnet traffic — then add narrowly scoped allow rules for the relevant source devices and service destinations.That also means the client can simply connect when it needs those services; it does not need broad, permanent network access merely because the Headscale app is installed. And if a device is lost, removing that node from Headscale cuts off its network access immediately.
So I would still favour: private services behind Headscale plus a deny-by-default policy, instead of managing a separate mTLS PKI for several native mobile apps. The latter is viable, but it is solving device/network admission at the application TLS layer when you already operate a tool designed to do it at the network layer. Headscale explicitly supports access control policies for restricting traffic between nodes, and policies can deny all traffic by default until specific access is granted.
Ah, interesting. Looks like using grants does resemble what I envision and probably easier to set up that mTLS. I’ll certainly explore that!
It’s a good option if you can’t do anything better, like a VPN. Because there is always the risk of an authentication bypass vulnerability. The less attack surface, the better.
I am using mTLS implemented by nginx to access my services like Home Assistant, Paperless, Tandoor, Immich and so on. Clients are Windows, Linux and Android based so no iOS experience. Adding new clients can be tricky if you need to figure out how to provide the certificate first. Once it works, it’s rock solid






