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 include sip.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:

ToolUse
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:

  1. Provision: generate certs, copy to host, configure modparams.
  2. 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.
  3. Verify: tls_check_all_expiry() + a test connection.
  4. Monitor: schedule tls_check_all_expiry(warn_days=30) daily; alert on hits.
  5. Rotate before expiry: swap *.crt / *.key files in place, then tls_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=1 is set, but the CA cert isn't in ca_list. Add it.
  • "no certificate present"require_cert=1 and 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 versionStatusUse?
SSLv2 / SSLv3brokenNEVER
TLS 1.0deprecated, brokenNEVER
TLS 1.1deprecatedNEVER
TLS 1.2widely deployedYES (minimum)
TLS 1.3preferredYES (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.