Generating Let’s Encrypt certificates for internal services behind private IPs and NAT isn’t as simple as doing it for servers with a public IP. When a service is reachable over port 80 or 443, you can just use the standard HTTP-01 challenge. For private services though, your main option is the DNS-01 challenge.
The problem with the DNS challenge is that unless you automate it, you have to manually create TXT records every three months to renew your certificates. You can automate this using your DNS provider’s API key, but most providers only give you an API key that has full write access to your entire account or DNS zone. If that key ever leaks from one of your servers, an attacker can take over your whole domain.
The good news is, you don’t have to expose your main DNS zone to automate certificates. In this post, we’ll look at how to automate Let’s Encrypt certificate generation without giving away full access to your domain:
- Delegating a dedicated
acmesubdomain from your DNS provider to deSEC.io - Using a
CNAMEchallenge alias so Let’s Encrypt checks the delegated subzone - Creating a locked down deSEC API token that can only write
TXTrecords to_acme-challenge
For this setup, I am using deSEC.io a non profit security focused managed DNS provider. It lets you create a subdomain directly as a standalone DNS zone and supports fine grained token policies.
Creating an Account on deSEC and Adding Your Subzone
First, create an account on desec.io and log in. We are going to delegate acme.tadiosabebe.com from GoDaddy to deSEC by creating a child zone in deSEC.
- On the Domain Management page, click the + button.
- Enter
acme.tadiosabebe.comas the domain name and click Save. - A dialog will pop up showing two authoritative nameservers (
ns1.desec.io.andns2.desec.org.) along withDSandDNSKEYrecords.
If your domain name on GoDaddy has DNSSEC disabled, you can ignore the DS records.
Delegating the acme Subdomain from GoDaddy
Next, log in to your GoDaddy DNS management page for tadiosabebe.com and add three records: two NS records to hand over acme.tadiosabebe.com to deSEC, and one CNAME record to redirect Let’s Encrypt challenge checks to that new subzone.
| Type | Name | Value | What it does |
|---|---|---|---|
NS |
acme |
ns1.desec.io. |
Delegates acme.tadiosabebe.com to deSEC |
NS |
acme |
ns2.desec.org. |
Secondary deSEC nameserver for acme.tadiosabebe.com |
CNAME |
_acme-challenge |
_acme-challenge.acme.tadiosabebe.com. |
Points main domain ACME checks to the deSEC subzone |
After waiting a few minutes for DNS to update, check that the records are working properly using dig:
dig @1.1.1.1 acme.tadiosabebe.com NS +short
# Expected output: ns1.desec.io. and ns2.desec.org.
dig @1.1.1.1 _acme-challenge.tadiosabebe.com CNAME +short
# Expected output: _acme-challenge.acme.tadiosabebe.com.
Creating a Scoped deSEC API Token
By default an API token can change anything in your zone. To lock this down we will first create a temporary admin token in the web UI, and then use the deSEC API to create a restricted token that can only touch _acme-challenge.acme.tadiosabebe.com.
1. Generate an Admin Token
In the deSEC web interface:
- Go to Token Management and click the + button.
- Set the name to
admin-token. - Turn on Can create domain and Can delete domains.
- Open Advanced settings, turn on Can manage tokens, and click Save.
- Copy the generated secret token right away, as it won’t be shown again.
2. Create an Unprivileged Token via CLI
Export your admin token in your terminal and run the following curl command to create a new token with no domain or token management permissions:
export ADMIN_TOKEN="YOUR_ADMIN_TOKEN"
curl -s -X POST https://desec.io/api/v1/auth/tokens/ \
-H "Authorization: Token ${ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"name": "acme-tadios-dns01", "perm_create_domain": false, "perm_delete_domain": false, "perm_manage_tokens": false}'
From the JSON output, copy the id (the token UUID) and the token (the secret value)
3. Apply a Default Deny Policy and Allow Only TXT Writes
Now we set up two RRset policies on the new token:
- Default deny rule (
domain: null,subname: null,type: null→perm_write: false) so the token cannot edit any records by default. - Specific allow rule (
domain: acme.tadiosabebe.com,subname: _acme-challenge,type: TXT→perm_write: true) so it can only write theTXTrecord Let’s Encrypt needs.
Replace PASTE_TOKEN_UUID_HERE and PASTE_TOKEN_SECRET_HERE with the values from the previous output and run:
export NEW_TOKEN_ID="PASTE_TOKEN_UUID_HERE"
export DEDYN_TOKEN="PASTE_TOKEN_SECRET_HERE"
# 1. Create a default deny-all write policy
curl -s -X POST "https://desec.io/api/v1/auth/tokens/${NEW_TOKEN_ID}/policies/rrsets/" \
-H "Authorization: Token ${ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"domain": null, "subname": null, "type": null, "perm_write": false}'
# 2. Allow TXT writes only on _acme-challenge.acme.tadiosabebe.com
curl -s -X POST "https://desec.io/api/v1/auth/tokens/${NEW_TOKEN_ID}/policies/rrsets/" \
-H "Authorization: Token ${ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"domain": "acme.tadiosabebe.com", "subname": "_acme-challenge", "type": "TXT", "perm_write": true}'
4. Test the Scoped Token
Let’s verify that the new token works by creating a test TXT record on _acme-challenge.acme.tadiosabebe.com:
curl -i -X POST https://desec.io/api/v1/domains/acme.tadiosabebe.com/rrsets/ \
-H "Authorization: Token ${DEDYN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"subname": "_acme-challenge", "type": "TXT", "ttl": 3600, "records": ["\"test-validation-token\""]}'
If you get an HTTP/2 201 response, your scoped token is working properly. Once you confirm it works, you can delete the temporary admin-token from the deSEC web UI so only the restricted token remains active.
Reusing the Same Token for Future Subdomains
One nice thing about this setup is that you don’t need to create new deSEC zones or tokens if you want wildcard certificates for other subdomains later on, like *.lab.tadiosabebe.com.
You just add another CNAME record in GoDaddy:
- Type:
CNAME - Name:
_acme-challenge.lab - Value:
_acme-challenge.acme.tadiosabebe.com.
Then pass --challenge-alias acme.tadiosabebe.com in your ACME client, and it will reuse the exact same locked down token and TXT record policy.
That was it, now you can use desec.io API token created on the previous steps to automate Let’s Encrypt certificate generation using DNS-01 challenge without exposing the DNS zone from your DNS provider. In the next post we will walk through how to plug this scoped deSEC token into pfSense to automatically issue and renew wildcard certificates and utilize the certificates for HAProxy: