Back to Guides
DCV AutomationTLS-ALPN-01

How to Automate TLS-ALPN-01 Validation

Prove domain control inside the TLS handshake itself — no files, no DNS, just TLS. This guide covers TLS-ALPN-01 validation: how it works, when to use it, and step-by-step implementation for Caddy, Traefik, lego, and acme.sh.

15 min readFebruary 2026Implementation Guide
TLS-ALPN-01 validation flow diagram showing ACME CA connecting to port 443 with acme-tls/1 ALPN protocol

Watch: TLS-ALPN-01 Automation Explained

Not sure if TLS-ALPN-01 automation should be your first priority? The PKI Priority Planner analyzes your environment and tells you what to focus on first.

Assess Your Priorities

📍 Part of the DCV Automation Series
This guide covers TLS-ALPN-01 specifically. Not sure which method you need?
→ Start with Which DCV Method Should You Automate?
→ See also: DNS-01 Automation | HTTP-01 Automation

Your firewall team blocked port 80. Your DNS provider doesn't have an API. But port 443 is wide open because you're already serving HTTPS traffic.

TLS-ALPN-01 was built for exactly this scenario. Instead of placing files on a web server or creating DNS records, it proves domain control inside the TLS handshake itself — no HTTP requests, no DNS changes, no file placement.

Who this is for: SysAdmins and DevOps engineers running reverse proxies (Caddy, Traefik) or using standalone ACME clients (lego, acme.sh) who need certificate automation when port 80 is blocked and DNS API access isn't available.

What Is TLS-ALPN-01?

TLS-ALPN-01 proves you control a domain by presenting a specially crafted self-signed certificate during a TLS handshake on port 443 — no files, no DNS, just TLS.

How It Works (Step by Step)

1

Request certificate

ACME client asks for a cert for example.com

2

CA responds with challenge token

Let's Encrypt returns a random token

3

Client generates self-signed cert

Contains acmeIdentifier extension with SHA-256 of key authorization, SAN set to only the domain being validated

4

Configure ALPN responder

Server configured to respond to ALPN protocol "acme-tls/1"

5

Client tells CA: "ready"

Signals validation can begin

6

CA connects to port 443

CA connects with ALPN extension: "acme-tls/1"

7

Server responds with self-signed cert

NOT the production cert — only the validation cert

8

CA verifies token

Reads acmeIdentifier extension, confirms domain control

9

Certificate issued

Client installs real cert, removes self-signed cert, resumes normal TLS

Why This Is Clever

ALPN (Application-Layer Protocol Negotiation) is a TLS extension that lets the client and server agree on a protocol during the handshake. Normal HTTPS uses h2 or http/1.1. TLS-ALPN-01 uses acme-tls/1 — a protocol that exists only for ACME validation. No legitimate client will ever request it, so the validation certificate is never served to real users.

Want to see this flow interactively? Try the ACME Protocol Demo →

Self-Signed Certificate Requirements (RFC 8737)

  • Must be self-signedNot signed by any CA
  • SAN must contain ONLY the domain being validatedNo extra SANs, no IP addresses, no wildcards — even if the final certificate is a wildcard, the validation cert cannot have wildcards in SAN
  • Must include the acmeIdentifier extensionOID 1.3.6.1.5.5.7.1.31 containing the SHA-256 hash of the key authorization string
  • Extension must be marked criticalThe acmeIdentifier extension MUST be critical
  • No application dataAfter the TLS handshake completes, the connection closes immediately. The acme-tls/1 protocol carries zero application data.

Wait, a self-signed cert on production?

Don't panic. The self-signed certificate is only served to connections requesting the acme-tls/1 ALPN protocol. Your real users connecting with h2 or http/1.1 never see it. Most security scanners will never see this cert if they use normal ALPNs. The ACME client swaps it in, validation happens (usually in seconds), and it's removed. Most implementations handle this transparently.

When to Use TLS-ALPN-01

The Sweet Spot

TLS-ALPN-01 is ideal when:

  • Port 80 is blocked — Corporate firewalls, ISP restrictions, security policy
  • No DNS API access — Your DNS provider doesn't offer API control, or DNS changes require change tickets
  • You're already terminating TLS — Load balancers, reverse proxies, CDN edges
  • You want validation at the TLS layer — Clean separation of concerns, no web server file management
  • You need instant validation — No DNS propagation delays

When NOT to Use TLS-ALPN-01

  • Wildcard certificates — Not supported. Use DNS-01 instead.
  • Port 443 not publicly reachable — The CA must connect to your port 443 from the internet.
  • Behind a CDN that doesn't pass ALPN — If your CDN terminates TLS before traffic reaches your origin, it may strip the acme-tls/1 ALPN protocol. Example: Cloudflare in full-proxy mode terminates TLS at the edge.
  • Server/LB doesn't support custom ALPN — Your TLS termination point must serve different certificates based on the negotiated ALPN protocol.
  • Multiple servers behind the same IP — All servers must be able to respond to the challenge.
  • certbot is your only option — certbot still does not support TLS-ALPN-01 as of early 2026 (check certbot docs for updates).

Quick Decision Check

QuestionIf YESIf NO
Need wildcard certs?❌ Stop → DNS-01✅ Continue
Port 443 reachable from internet?✅ Continue❌ Stop → DNS-01
TLS termination point supports ALPN?✅ TLS-ALPN-01 works❌ Stop → DNS-01 or HTTP-01
Behind a CDN?⚠️ Verify ALPN passthrough✅ You're clear

Tool Compatibility Matrix

ToolTLS-ALPN-01HowNotes
Caddy✅ Built-in (default)AutomaticUses TLS-ALPN-01 by default, falls back to HTTP-01
Traefik✅ tlsChallengetlsChallenge: trueNative support in v2+
lego✅ --tls flag--tlsStandalone TLS server on port 443
acme.sh✅ --alpn flag--alpnStandalone or with HAProxy integration
dehydrated✅ with hookHook scriptRequires external ALPN responder
cert-manager✅ SupportedSolver configUses ingress-shim or solver pods
certbot❌ Not supportedUse HTTP-01, DNS-01, or a different client
HAProxy🔜 PlannedUse acme.sh integration for now
Nginx⚠️ VariesExternalRequires Lua module or external responder

Implementation: Caddy

Caddy has first-class TLS-ALPN-01 support — it's the default challenge type. Just specify your domain and Caddy handles everything.

Minimal Caddyfile

This is all you need:

example.com {
    root * /var/www/html
    file_server
}

Caddy automatically obtains and renews certificates. TLS-ALPN-01 is tried first; if it fails, Caddy falls back to HTTP-01. If Caddy can't bind port 80 (blocked or another process), it will still succeed via TLS-ALPN-01 as long as 443 is reachable.

Explicit TLS-ALPN-01 Only

To force TLS-ALPN-01 and disable HTTP-01:

{
    acme_ca https://acme-v02.api.letsencrypt.org/directory
    # Disable HTTP challenge entirely
    http_port 0
}

example.com {
    tls {
        # TLS-ALPN-01 is used automatically when http_port is 0
    }
    root * /var/www/html
    file_server
}

Multi-Domain

example.com, www.example.com, api.example.com {
    root * /var/www/html
    file_server
}

Verify Certificate

# Check certificate
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates -issuer

# Check Caddy logs for issuance
journalctl -u caddy | grep -i "certificate|acme"

Common Pitfalls

  • Port conflict: Another process binding to port 443. Check with ss -tlnp | grep 443
  • Docker networking: Ensure port 443 is published (-p 443:443) and the container can reach the internet
  • Test with staging first: Add acme_ca https://acme-staging-v02.api.letsencrypt.org/directory in a global block to avoid rate limits during experiments

Implementation: Traefik

Docker Compose with TLS Challenge

# docker-compose.yml
version: "3.8"
services:
  traefik:
    image: traefik:v3.0
    command:
      - "--providers.docker=true"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.myresolver.acme.tlschallenge=true"
      - "--certificatesresolvers.myresolver.acme.email=admin@example.com"
      - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
    ports:
      - "443:443"
    volumes:
      - "./letsencrypt:/letsencrypt"
      - "/var/run/docker.sock:/var/run/docker.sock:ro"

  whoami:
    image: traefik/whoami
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`example.com`)"
      - "traefik.http.routers.whoami.entrypoints=websecure"
      - "traefik.http.routers.whoami.tls.certresolver=myresolver"

Static YAML Configuration

# traefik.yml
entryPoints:
  websecure:
    address: ":443"

certificatesResolvers:
  myresolver:
    acme:
      email: admin@example.com
      storage: /etc/traefik/acme.json
      tlsChallenge: {}  # TLS-ALPN-01

providers:
  file:
    filename: /etc/traefik/dynamic.yml

Verify Certificate

# Check Traefik dashboard for certificate status
curl -s http://localhost:8080/api/http/routers | jq '.[] | {name, tls}'

# Check certificate from outside
echo | openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -text | grep -A1 "Issuer"

Common Pitfalls

  • ALPN negotiation failure: Ensure no other service (like nginx) is terminating TLS before Traefik. Verify ALPN with: openssl s_client -connect example.com:443 -alpn 'acme-tls/1,h2'
  • mTLS conflict: Client certificate requirements can interfere with ACME validation
  • Permissions: The acme.json file must be readable/writable (mode 600)
  • Clustered setups: Persist /letsencrypt to durable storage (NFS, host mount, etc.) so all Traefik replicas share the same certificates

Implementation: lego

lego is a powerful standalone ACME client written in Go. It supports TLS-ALPN-01 via the --tls flag.

Basic Usage

# First run - register account and get certificate
lego --email admin@example.com --domains example.com --tls run

# Renew certificate
lego --email admin@example.com --domains example.com --tls renew

Important: lego's --tls flag starts a standalone TLS server on port 443. You must stop any existing web server first.

Multi-Domain Certificate

lego --email admin@example.com \
    --domains example.com \
    --domains www.example.com \
    --domains api.example.com \
    --tls run

Cron Automation

# /etc/cron.d/lego-renew
0 3 * * * root systemctl stop nginx && \
    /usr/local/bin/lego --email admin@example.com --domains example.com --tls renew --renew-hook="systemctl start nginx" || \
    systemctl start nginx

The --renew-hook runs only when a certificate is actually renewed. The trailing || systemctl start nginx ensures nginx restarts even if renewal fails.

Caution: This stop-the-world pattern is risky on single-node critical services. For zero-downtime renewals, consider HAProxy with acme.sh integration or a sidecar approach.

IPv6 Note: Ensure both A and AAAA DNS records point to the host running lego. If you have mismatched AAAA records, temporarily remove them during testing — the CA may connect via IPv6 to an unreachable address.

Certificate Location

By default, lego stores certificates in .lego/certificates/:

.lego/
├── accounts/
│   └── acme-v02.api.letsencrypt.org/
│       └── admin@example.com/
└── certificates/
    ├── example.com.crt      # Full chain
    ├── example.com.key      # Private key
    └── example.com.issuer.crt

Implementation: acme.sh

acme.sh is a popular shell-based ACME client with TLS-ALPN-01 support via the --alpn flag.

Standalone Mode

# Install acme.sh
curl https://get.acme.sh | sh -s email=admin@example.com

# Issue certificate using TLS-ALPN-01
acme.sh --issue --alpn -d example.com

# Install certificate to a directory
acme.sh --install-cert -d example.com \
    --key-file /etc/ssl/private/example.com.key \
    --fullchain-file /etc/ssl/certs/example.com.pem \
    --reloadcmd "systemctl reload nginx"

Note: Like lego, standalone mode requires port 443 to be free. Stop your web server before running.

HAProxy Integration

For HAProxy environments, acme.sh can integrate without downtime:

# Issue certificate with HAProxy deploy hook
acme.sh --issue --alpn -d example.com \
    --deploy --deploy-hook haproxy

# Or manually deploy to HAProxy
acme.sh --install-cert -d example.com \
    --key-file /etc/haproxy/certs/example.com.key \
    --fullchain-file /etc/haproxy/certs/example.com.pem \
    --ca-file /etc/haproxy/certs/ca.pem \
    --reloadcmd "systemctl reload haproxy"

Multi-Domain

acme.sh --issue --alpn \
    -d example.com \
    -d www.example.com \
    -d api.example.com

Preferred pattern for zero-downtime: Use the HAProxy deploy hook instead of standalone mode. HAProxy integration pushes new certificates via the Runtime API without reloading the service — unlike lego's stop-start approach.

Cron tip: acme.sh installs its own cron job by default. You usually don't need to add your own — verify with acme.sh --cron --home ~/.acme.sh in your logs.

Troubleshooting

Cannot negotiate ALPN protocol

Symptom: Error: "no ALPN negotiated" or "ALPN protocol not supported"

Causes:

  • Server not configured to respond to acme-tls/1 ALPN
  • CDN or load balancer stripping ALPN extension
  • Firewall blocking TLS negotiation

Fix: Verify your TLS termination point supports ALPN.

openssl s_client -connect example.com:443 -alpn "acme-tls/1"

Connection Refused on Port 443

Symptom: CA cannot connect to validation endpoint

Causes:

  • Firewall blocking inbound port 443
  • No service listening on port 443
  • DNS not pointing to correct IP

Fix: Check firewall rules, verify DNS, ensure service is running: ss -tlnp | grep 443

Extra SANs Issue (Historical)

Symptom: Validation fails despite correct setup

Causes:

  • Let's Encrypt had a bug in January 2022 where additional SANs in the validation cert were not properly rejected

Fix: This was fixed server-side. Ensure your ACME client is up to date and generates validation certs with only the single domain being validated.

RFC 8737 requires the validation certificate SAN to contain ONLY the domain being validated.

Unauthorized Error

Symptom: CA rejects the challenge with "unauthorized"

Causes:

  • acmeIdentifier extension missing or incorrect
  • Wrong key authorization hash
  • Certificate served to wrong ALPN protocol

Fix: Check ACME client logs for exact error. Ensure you're using a supported and up-to-date client.

Security Scanner False Positives

Symptom: Scanner reports "self-signed certificate detected"

Causes:

  • Scanner connecting with acme-tls/1 ALPN and seeing the validation cert

Fix: Configure scanner to use standard ALPN protocols only (h2, http/1.1). In most scanners, set the protocol to HTTPS with HTTP/1.1 or HTTP/2 only. The self-signed cert is never served to normal HTTPS traffic.

Comparison: TLS-ALPN-01 vs HTTP-01 vs DNS-01

AspectTLS-ALPN-01HTTP-01DNS-01
Port Required443 (HTTPS)80 (HTTP)None (DNS only)
Infrastructure AccessTLS termination pointWeb server file systemDNS zone/API
Wildcard Support❌ No❌ No✅ Yes
Propagation DelayNone (instant)None (instant)Variable (TTL)
CDN Compatible⚠️ Varies⚠️ Varies✅ Yes
Certbot Support❌ No✅ Yes✅ Yes
RFCRFC 8737RFC 8555RFC 8555
Primary Failure ModeALPN stripped or 443 unreachablePort 80 blocked or HTTP routing misconfigDNS propagation or misconfigured TXT record

Renewal Automation

Zero-Downtime Tools (Built-in Renewal)

Caddy and Traefik handle renewal automatically. No cron jobs needed — they monitor certificate expiry and renew in the background without interrupting traffic.

# Caddy - nothing to configure, automatic renewal is built-in
# Check certificate status
caddy list-certificates

# Traefik - automatic if certificatesResolvers is configured
# Check acme.json for expiry dates
cat /letsencrypt/acme.json | jq '.myresolver.Certificates[].domain'

Standalone Tools (Require Cron)

For lego and acme.sh, you need to schedule renewal:

# lego cron (requires stopping web server)
0 3 * * * systemctl stop nginx && lego --email admin@example.com --domains example.com --tls renew && systemctl start nginx

# acme.sh cron (installs its own cron by default)
# Manual renewal
acme.sh --renew -d example.com

# Force renewal (ignores 30-day window)
acme.sh --renew -d example.com --force

Monitoring Commands

# Check certificate expiry
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates

# Check days until expiry
echo | openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -checkend 2592000
# Exit code 0 = valid for 30+ days, 1 = expiring within 30 days

# List all lego certificates
ls -la ~/.lego/certificates/

# List all acme.sh certificates
acme.sh --list

Related Resources