TLS from scratch
April 28, 2026 · View on GitHub
Generate certificates, wire them into OpenSIPS, debug handshake failures, rotate before expiry. Aimed at the engineer who has shipped plain-UDP SIP and now needs to add TLS for real.
When you need TLS for SIP
- A carrier requires sigtran-over-TLS (sips) on their interconnect.
- WebRTC clients (which mandate WSS, not WS).
- Privacy / regulatory: encrypted signalling end-to-end.
- Mutual TLS as carrier authentication (instead of digest auth).
If none of those apply, plain UDP/TCP is fine for internal traffic. TLS for SIP adds CPU cost (handshake + per-packet encryption), an operational burden (cert rotation), and a new failure mode (handshake errors, cipher mismatches, expired certs). Don't add it just because "TLS = secure."
Three certificates you actually need
1. Server certificate (the proxy presents this)
A standard X.509 cert with:
- CN / SAN matching the SIP domain UAs / peers connect to. If the
proxy serves
sip.example.com, the cert's SAN must includesip.example.com. WebRTC UAs validate the SNI against the cert. - Key usage: Digital Signature, Key Encipherment.
- Extended key usage: TLS Web Server Authentication.
- Signed by a CA the peers trust (Let's Encrypt, internal PKI, carrier-supplied CA, etc.).
2. CA certificate(s) to validate peer presentations
Only required if you do mutual TLS — i.e., you require carriers to present a client cert. Otherwise skip; clients (UAs) typically don't authenticate via TLS, they authenticate via SIP digest after the TLS connection is up.
3. Private key
The matching private key for the server cert. Plain RSA 2048 is fine for SIP (4096 burns CPU; ECDSA P-256 is fine and faster).
Generating certs (lab / staging)
For real production use Let's Encrypt or an internal PKI. For staging:
# CA — only needed if you'll do mutual TLS
openssl genrsa -out ca.key 2048
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
-subj "/CN=My SIP Lab CA"
# Server cert + key, signed by the lab CA
openssl genrsa -out sip.key 2048
openssl req -new -key sip.key -out sip.csr \
-subj "/CN=sip.example.com"
openssl x509 -req -days 365 \
-in sip.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out sip.crt \
-extfile <(printf "subjectAltName=DNS:sip.example.com,DNS:sips.example.com")
# Verify
openssl x509 -in sip.crt -text -noout
For Let's Encrypt the standard certbot workflow produces
fullchain.pem (cert + intermediates) and privkey.pem. Use the
fullchain — never the bare cert; intermediate-missing handshake errors
are the most common production TLS bug.
Wiring into OpenSIPS
Minimum viable
loadmodule "proto_tls.so"
loadmodule "tls_mgm.so"
modparam("tls_mgm", "server_domain", "default")
modparam("tls_mgm", "match", "[default]server_name=*")
modparam("tls_mgm", "certificate", "[default]/etc/opensips/tls/sip.crt")
modparam("tls_mgm", "private_key", "[default]/etc/opensips/tls/sip.key")
# Don't require client certs (most UAs don't have them)
modparam("tls_mgm", "verify_cert", "[default]0")
modparam("tls_mgm", "require_cert", "[default]0")
# Bind a TLS listener
listen=tls:0.0.0.0:5061
That gets you accepting TLS-from-anywhere. Refinements below.
Multiple TLS server domains (SNI)
If the proxy needs to present different certs to different peers (carrier-A wants their CA-issued cert; UAs want the LE cert for sip.example.com):
modparam("tls_mgm", "server_domain", "carrier_a")
modparam("tls_mgm", "server_domain", "default")
modparam("tls_mgm", "match", "[carrier_a]server_name=peer-a.carrier-net")
modparam("tls_mgm", "match", "[default]server_name=*")
modparam("tls_mgm", "certificate", "[carrier_a]/etc/opensips/tls/carrier-a.crt")
modparam("tls_mgm", "private_key", "[carrier_a]/etc/opensips/tls/carrier-a.key")
modparam("tls_mgm", "certificate", "[default]/etc/opensips/tls/sip.crt")
modparam("tls_mgm", "private_key", "[default]/etc/opensips/tls/sip.key")
The match rule is evaluated against the SNI hostname the client
sent. First match wins.
Mutual TLS (client cert required)
modparam("tls_mgm", "verify_cert", "[carrier_a]1")
modparam("tls_mgm", "require_cert", "[carrier_a]1")
modparam("tls_mgm", "ca_list", "[carrier_a]/etc/opensips/tls/carrier-a-ca.crt")
Client (carrier-A) must present a cert signed by carrier-a-ca.crt or
the handshake fails before any SIP message is exchanged. This is
strong authentication — peers without a private key cannot connect
period.
Hardening modparams
# Min TLS version
modparam("tls_mgm", "tls_method", "[default]TLSv1_2+")
# Only secure ciphers
modparam("tls_mgm", "cipher_list",
"[default]ECDHE+AESGCM:ECDHE+CHACHA20:!aNULL:!MD5:!DSS")
# DH params for forward secrecy
modparam("tls_mgm", "dhparams", "[default]/etc/opensips/tls/dhparams.pem")
Generate dhparams.pem once: openssl dhparam -out dhparams.pem 2048.
Lifecycle: tools the project ships
The MCP tool surface for TLS:
| Tool | Use |
|---|---|
tls_list() | What domains are configured + their cert paths |
tls_check_expiry(domain) | Days until expiry for one domain |
tls_check_all_expiry(warn_days=30) | Sweep every domain; flag any expiring within N days |
tls_handshake_errors(limit=50) | Recent handshake failure counts + reasons |
tls_connections() | Active TLS connections + lifetimes |
tls_statistics() | Aggregate (handshakes, sessions, bytes, errors) |
tls_reload() | Rebuild domain table after a cert rotation |
Lifecycle workflow:
- Provision: generate certs, copy to host, configure modparams.
- First reload:
tls_reload(). If certs are unreadable / expired / chain-broken, OpenSIPS logs the error and refuses to load that domain — the rest of the proxy continues. - Verify:
tls_check_all_expiry()+ a test connection. - Monitor: schedule
tls_check_all_expiry(warn_days=30)daily; alert on hits. - Rotate before expiry: swap
*.crt/*.keyfiles in place, thentls_reload(). No restart needed.
Debugging handshake failures
Symptom: peer can't establish a TLS connection. SIP never even gets sent because the TLS layer fails first.
Step 1 — tls_handshake_errors()
Returns the most recent failure reasons. The strings are openssl's; common ones:
- "unsupported protocol" — peer wants TLSv1.0 / 1.1, you only allow 1.2+. Either bump the policy or accept the older version.
- "no shared cipher" — peer's cipher list and yours don't overlap. Check
cipher_list; loosen if necessary. - "certificate expired" — yours or theirs. Check both.
- "unknown ca" — peer presented a cert and
verify_cert=1is set, but the CA cert isn't inca_list. Add it. - "no certificate present" —
require_cert=1and the peer didn't send one. Either provide one or relax the requirement.
Step 2 — openssl s_client from the proxy host
openssl s_client -connect peer.example.com:5061 \
-servername peer.example.com -showcerts
Shows the cert the peer presents. Check the chain, the dates, the SAN list, the cipher selected.
Step 3 — packet capture
sudo tcpdump -i any -w tls.pcap port 5061
Open in Wireshark. Filter tls.handshake.type == 1 for ClientHello,
tls.handshake.type == 2 for ServerHello, tls.alert_message for
the failure alert. The alert tells you which side bailed and why.
Step 4 — temporarily lower TLS strictness
If you suspect cipher / version mismatch, relax briefly to confirm:
modparam("tls_mgm", "tls_method", "[default]TLSv1+") # accepts older
modparam("tls_mgm", "cipher_list", "[default]DEFAULT") # all openssl defaults
Get the connection working, then tighten back step by step. Don't leave production loose — each loosening is a real security regression.
Common patterns
"I rotated a cert, OpenSIPS still uses the old one"
tls_reload() rebuilds the domain table from the modparam
declarations. If the path in the modparam still points at the
old file, the reload picks up nothing new. Either:
- Replace the files in place (preserve the path), then
tls_reload(). - Or change the modparam paths in the config and
cfg_reload.
"Certbot rotated my cert; OpenSIPS is now broken"
Let's Encrypt's hook installs new files at the same path (good), but
on some distros the file group changes from opensips:opensips to
root:root mid-rotation (bad). Add to your certbot deploy hook:
chown opensips:opensips /etc/opensips/tls/*.{crt,key}
chmod 640 /etc/opensips/tls/*.key
opensips-cli -x mi tls_reload
"WebRTC clients can't connect"
WSS over TLS has an extra constraint: the cert must be valid against a CA the browser trusts. A self-signed cert that works for SIP endpoints will be rejected by Chrome / Firefox. Use Let's Encrypt or a public CA for any WebRTC-facing TLS endpoint.
"My carrier presents an expired cert"
That's their problem, not yours. But if you must connect anyway:
verify_cert=0 for that carrier's domain. Document this in the
config — every "verify_cert=0" line in production should have a
comment with the date you flagged it as a known issue with the
peer.
Cipher / TLS-version baseline (April 2026)
| TLS version | Status | Use? |
|---|---|---|
| SSLv2 / SSLv3 | broken | NEVER |
| TLS 1.0 | deprecated, broken | NEVER |
| TLS 1.1 | deprecated | NEVER |
| TLS 1.2 | widely deployed | YES (minimum) |
| TLS 1.3 | preferred | YES (best when both sides support) |
Cipher list to start from:
ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:!aNULL:!MD5:!DSS:!3DES:!RC4
Test with:
nmap --script ssl-enum-ciphers -p 5061 sip.example.com
If anything in the "weak" / "broken" / "deprecated" buckets shows up, tighten the cipher list.