Let's Encrypt certificates for MikroTik RouterOS via ACMESpider and DNS-01 challenge
Trusted certificates at home
With the advent of Let’s Encrypt, it’s now easy not only to secure your public website with an “official” TLS certificate, but you can now also use these certificates in your home networks. The benefit over self-signed certificates is that the certificates issued by Let’s Encrypt are automatically trusted by e.g. your iPhone, Linux or Windows box. No need to import custom CA certificates on your devices, or to live with untrusted certificate warnings every time you open a self-hosted website or service.
This post shows how to get Let’s Encrypt certificates for a MikroTik router in a private network, using ACMESpider to turn the HTTP-01 challenge into a DNS-01 challenge.
In order for Let’s Encrypt (or any other Certificate Authority (CA) in general) to issue a
certificate for someone, you have to prove to the CA1 that you are the legitimate owner of the
domain you request the certificate for. Otherwise anyone could obtain a certificate for e.g.
google.com and do nasty things with it.
To automate the certificate issuing process, the Automatic Certificate Management Environment (ACME) protocol, which is standardized in RFC 8555, is used. ACME uses so called challenges to handle the certificate issuing.
HTTP-01 challenge
If the CA can reach your device on port 80 via its public DNS name, you can use the HTTP-01
challenge. During the issuing process you prove control of the domain by placing a token that
the CA gave you in a standardized path on your web server (/.well-known/acme-challenge/). The
ACME server (Let’s Encrypt) then checks if it can access the token under the expected path, which
proves that you control the domain. Finally the certificate is issued.
This works well if for example you need a certificate for a public web server or for a machine
which is directly accessible from the internet, addressable through public DNS (e.g.
www.myserver.com points to your machine). If however the machine is in a private network like
your home net, the HTTP-01 challenge gets complicated, and you need to use port forwardings,
name-based virtual hosting etc. in order to have your machine finally communicate with the ACME
server.
In these scenarios the DNS-01 challenge is the easier approach.
DNS-01 challenge
In the DNS-01 challenge you prove control of the domain by creating a specific DNS record (type
TXT). The ACME server again sends you a token, and the ACME client then creates a TXT record
at _acme-challenge.<domain> derived from the token. The ACME server checks for the TXT record
for a while, and when it shows up with the expected value, the certificate is issued.
In order for your ACME client to create the TXT record at your DNS provider (e.g. Cloudflare,
AWS, Hetzner etc.) the client needs a plugin to be able to communicate with the DNS provider,
since every provider uses different APIs here. That’s where tools and libraries like
acme.sh, lego
or caddy-dns come in, supporting 100+ of DNS providers.
ACME on MikroTik RouterOS
MikroTik routers come with an ACME client that allows you to obtain certificates from an ACME compliant server. Unsurprisingly, only the HTTP-01 challenge is supported, since supporting DNS-01 would require integrations for dozens of DNS provider APIs.
If your router however is not internet-facing, using the HTTP-01 challenge can become complicated as I described above.
ACMESpider
ACMESpider by Lachlan Davidson acts as HTTP-01 ACME server in your private network and transforms the challenge into a DNS-01 challenge. This enables devices that only support the HTTP-01 challenge to get certificates easily. ACMESpider uses the mentioned lego library and supports 100+ DNS providers out of the box.
HTTP-01 DNS-01
Challenge Challenge
┌─────────────┐ ┌────────────┐ ┌───────────────┐
│ ACME Client ├────────►│ ACMESpider ├───────►│ Let's Encrypt │
│ │◄────────┤ │ └──────┬────────┘
└─────────────┘ Fetches └──────┬─────┘ │ Checks TXT record
/.well-known/ │ ┌──────▼────────┐
acme-challenge │ │ Your │
└─────────────►│ DNS Provider │
Sets TXT └───────────────┘
record
Installation of ACMESpider is straightforward. If you run it with TLS support, it also uses ACME to obtain a server certificate for its own TLS endpoint. If you run ACMESpider behind a reverse proxy, you can disable TLS.
Easiest way to start ACMESpider is using docker:
docker run \
-d \
-p 443:443 \
--name acmespider \
-v acmespider_data:/data \
-e ACMESPIDER_HOSTNAME=acme.myhomelab.net \
-e ACMESPIDER_ACME_EMAIL="you@example.com" \
-e ACMESPIDER_ACME_TOS_ACCEPT=true \
-e CLOUDFLARE_DNS_API_TOKEN="$CF_API_TOKEN" \
-e CLOUDFLARE_ZONE_API_TOKEN="$CF_API_TOKEN" \
ghcr.io/lachlan2k/acmespider:latest
This starts ACMESpider and binds it to acme.myhomelab.net (in this example myhomelab.net is a
domain you own and Cloudflare is configured as the DNS provider. Make sure to scope the
Cloudflare API token to the single zone with DNS:Edit permission only)
Prerequisites for the setup:
acme.myhomelab.netresolves to the ACMESpider host on all clients.- The names of the clients (e.g.
ax3.myhomelab.net) resolve to the clients on the ACMESpider host. - Port 80 of the clients is reachable from ACMESpider, in order for the HTTP-01 challenge to work.
Be aware that any host that can reach ACMESpider and answer the HTTP-01 challenge for a name can get a publicly trusted certificate for any name in your zone. So restrict access to ACMESpider, e.g. with a firewall.
For a first test, run curl against ACMESpider:
$ curl -s https://acme.myhomelab.net/acme/directory | jq
{
"newNonce": "https://acme.myhomelab.net/acme/new-nonce",
"newAccount": "https://acme.myhomelab.net/acme/new-account",
"newOrder": "https://acme.myhomelab.net/acme/new-order",
"newAuthz": "https://acme.myhomelab.net/acme/new-authz",
"revokeCert": "https://acme.myhomelab.net/acme/revoke-cert",
"keyChange": "https://acme.myhomelab.net/acme/key-change",
"meta": {
"termsOfService": "",
"website": "",
"caaIdentities": [
""
],
"externalAccountRequired": false
}
}
(note the use of https and absence of curl’s -k (insecure TLS flag), since ACMESpider
automatically created a valid TLS certificate for itself)
First test with acme.sh
Before testing ACME with the MikroTik router, let’s try to obtain a certificate using
acme.sh on my local notebook
(testbox.myhomelab.net). ACMESpider runs on https://acme.myhomelab.net/acme/directory. I run it
on a Raspberry Pi 3 with OpenWrt behind an nginx reverse proxy server.
$ docker run --rm -it \
-v "$(pwd)/out":/acme.sh \
--net=host \
ghcr.io/acmesh-official/acme.sh --issue -d testbox.myhomelab.net --standalone \
--server https://acme.myhomelab.net/acme/directory
[Sat Nov 7 12:30:08 UTC 2026] Using CA: https://acme.myhomelab.net/acme/directory
[Sat Nov 7 12:30:08 UTC 2026] Standalone mode.
[Sat Nov 7 12:30:08 UTC 2026] Single domain='testbox.myhomelab.net'
[Sat Nov 7 12:30:09 UTC 2026] Getting webroot for domain='testbox.myhomelab.net'
[Sat Nov 7 12:30:09 UTC 2026] Verifying: testbox.myhomelab.net
[Sat Nov 7 12:30:09 UTC 2026] Standalone mode server
[Sat Nov 7 12:30:11 UTC 2026] Success
[Sat Nov 7 12:30:11 UTC 2026] Verification finished, beginning signing.
[Sat Nov 7 12:30:11 UTC 2026] Let's finalize the order.
[Sat Nov 7 12:30:11 UTC 2026] Le_OrderFinalize='https://acme.myhomelab.net/acme/order/uwax3KadkSzSVtpkDrLcdA/finalize'
[Sat Nov 7 12:30:11 UTC 2026] Order status is 'processing', let's sleep and retry.
[Sat Nov 7 12:30:15 UTC 2026] Polling order status: https://acme.myhomelab.net/acme/order/uwax3KadkSzSVtpkDrLcdA
[Sat Nov 7 12:30:15 UTC 2026] Order status is 'processing', let's sleep and retry.
[Sat Nov 7 12:30:18 UTC 2026] Polling order status: https://acme.myhomelab.net/acme/order/uwax3KadkSzSVtpkDrLcdA
[Sat Nov 7 12:30:18 UTC 2026] Order status is 'processing', let's sleep and retry.
[Sat Nov 7 12:30:21 UTC 2026] Polling order status: https://acme.myhomelab.net/acme/order/uwax3KadkSzSVtpkDrLcdA
[Sat Nov 7 12:30:21 UTC 2026] Order status is 'processing', let's sleep and retry.
[Sat Nov 7 12:30:24 UTC 2026] Polling order status: https://acme.myhomelab.net/acme/order/uwax3KadkSzSVtpkDrLcdA
[Sat Nov 7 12:30:24 UTC 2026] Downloading cert.
[Sat Nov 7 12:30:24 UTC 2026] Le_LinkCert='https://acme.myhomelab.net/acme/certificate/oJhNiUnrqhH8S2ZlgbNmxQ'
[Sat Nov 7 12:30:24 UTC 2026] Cert success.
-----BEGIN CERTIFICATE-----
MIIDkTCCAxegAwIBAgISBpgOej4ehWoiueDBXsEIYzoYMAoGCCqGSM49BAMDMDMx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQDEwNZ
RTIwHhcNMjYxMTA3MTEzMTUxWhcNMjcwMjA1MTEzMTUwWjAcMRowGAYDVQQDExF6
....
AjBmQ/FokaT3KYDVA8gs452dG5h7KHdJQHwkGVrEX7tWcXE/UxD7pKgF0970hWhA
dY0CMQDHHPcx7W/lAmBVGSHgk4zgAVCXgJbtUyz4l+R8FS4rPvorwYizCW9j42cl
ItpC3dI=
-----END CERTIFICATE-----
[Sat Nov 7 12:30:24 UTC 2026] Your cert is in: /acme.sh/testbox.myhomelab.net_ecc/testbox.myhomelab.net.cer
[Sat Nov 7 12:30:24 UTC 2026] Your cert key is in: /acme.sh/testbox.myhomelab.net_ecc/testbox.myhomelab.net.key
[Sat Nov 7 12:30:24 UTC 2026] The intermediate CA cert is in: /acme.sh/testbox.myhomelab.net_ecc/ca.cer
[Sat Nov 7 12:30:24 UTC 2026] And the full-chain cert is in: /acme.sh/testbox.myhomelab.net_ecc/fullchain.cer
That works. acme.sh is using the HTTP-01 challenge and is obtaining the certificate through
ACMESpider, which does the DNS-01 challenge outbound. For troubleshooting, logs of the ACMESpider
server are of value here (not shown).
Test on the MikroTik with RouterOS
Now let’s try to request a certificate directly from a MikroTik router running RouterOS. I tried it from my hAP ax³ with RouterOS 7.25, which trusts the Let’s Encrypt root of ACMESpider’s TLS certificate out of the box:
[admin@hAP] > /certificate/add-acme directory-url=https://acme.myhomelab.net/acme/directory domain-names=ax3.myhomelab.net
Looking at the ACMESpider logs in parallel, we see that the request fails with an Invalid JWS JSON error (JWS: JSON Web Signature):
Sat Nov 7 12:55:51 2026 daemon.err acmespider[18383]: time="2026-11-07T12:55:51Z" level=warning
msg="request problem" URI=/acme/new-account content_length=1501
error="urn:ietf:params:acme:error:malformed :: Invalid JWS JSON" latency_ms=0 method=POST
problem_detail="Invalid JWS JSON" problem_status=400
problem_type="urn:ietf:params:acme:error:malformed" remote_ip=127.0.0.1 response_size=0 status=200
user_agent="RouterOS 7.25"
Root cause and the fix
Adding temporary extra logging in ACMESpider jose.go reveals what RouterOS actually sends:
Sat Nov 7 12:55:51 2026 daemon.err acmespider[18383]: time="2026-11-07T12:55:51Z" level=error msg="
{\n \"protected\": \"ICAgIHsK...
The JSON is valid, but starts with whitespace before the leading {. Looking at the
code
we see that there is an additional validation of the body, which leads to the failure:
func extractJWS(requestBody []byte) (*jose.JSONWebSignature, *jose.Signature, error) {
if len(requestBody) == 0 || requestBody[0] != '{' {
return nil, nil, acme_controller.MalformedProblem("Invalid JWS JSON")
}
// ...
}
Since json.Unmarshal() and jose.ParseSigned() reject anything that is not a JWS JSON object
anyway, the check is redundant and can be
removed. After that the RouterOS client gets a
certificate as expected, making the homelab more secure.
As of this writing, the pull request is not merged yet, so the upstream docker image still fails
with RouterOS. Until then, build ACMESpider from the pull request branch.
Result
The router now gets a trusted Let’s Encrypt certificate through ACMESpider, without exposing
anything to the internet and without storing DNS credentials on the router. To use the
certificate for the router’s web interface and API, look up its name with /certificate/print
and assign it to the services:
[admin@hAP] > /ip/service/set www-ssl certificate=<certificate name>
[admin@hAP] > /ip/service/set api-ssl certificate=<certificate name>
References
- RFC 8555: Automatic Certificate Management Environment (ACME)
- Let’s Encrypt: Challenge Types
- ACMESpider
- ACMESpider pull request: Relaxed JWS validation in extractJWS
- MikroTik RouterOS documentation: Certificates
- lego DNS providers
Strictly speaking, in a PKI, verifying that a requester is entitled to a certificate is the job of the Registration Authority (RA), while the CA only signs. Let’s Encrypt bundles both roles in its ACME server. ↩︎