/Anatomy of a Kamailio Config
August 26, 2026
Kamailio
SIP
VoIP
Telephony
Kamailio is the workhorse of serious SIP deployments: a proxy that sits between
the wilds of the public telephone network and your application, and decides —
for every SIP message — whether to accept it, reject it, rewrite it, or relay
it. All of those decisions live in one place: kamailio.cfg.
Config files for proxies are usually boring. This one is not — it is your
routing logic, written in a small DSL of conditions, function calls, and
exit statements. This post takes a production-style request_route apart
line by line, so the next config you read (or write) makes sense.
The three parts of a kamailio.cfg
Every config file has the same skeleton:
1. Global parameters — listen sockets, logging, SIP timers
2. Modules — loadmodule + modparam lines
3. Routing blocks — where the actual logic livesGlobal parameters say where Kamailio listens (listen=udp:0.0.0.0:5060,
TLS sockets and so on) and how it behaves. Modules add capabilities —
tm.so gives you transaction state, sl.so stateless replies, permissions.so
address-based access control. modparam lines feed each module its settings.
Then come the routing blocks. The two you touch most:
request_route— runs once for every SIP request (INVITE, BYE, REGISTER, ...). The main entry point.route[NAME]— named blocks you call like functions from other routes, withroute(NAME).
There are also event-driven blocks — on_reply, failure_route,
branch_route, event_route — which the transaction machinery invokes
later, as replies and failures come back.
With that, let's read the main block.
request_route, line by line
Loop protection: Max-Forwards
if (!mf_process_maxfwd_header("10")) {
sl_send_reply("483", "Too Many Hops");
exit;
}SIP has a TTL for messages: the Max-Forwards header, decremented by every
proxy. mf_process_maxfwd_header("10") decrements it and fails if it hits
zero. Without this check, a routing loop between two proxies forwards the
same INVITE to each other forever — so it comes first, before the message
costs you anything. sl_send_reply fires a stateless reply (cheap, no
transaction created), and exit stops processing immediately.
Structural validation: sanity_check
if (!sanity_check("17895", "7")) {
xlog("L_WARN", "Malformed SIP from $si:$sp\n");
exit;
}Fuzzed packets, broken clients, and malformed messages from port scanners
all arrive at a public SIP port. sanity_check validates the message
structure — required headers, parseable URIs, sane Content-Length — before
anything else looks at it. The first argument is a bitmask of checks, the
second a minimum header count. Malformed junk gets logged (note the
pseudo-variables: $si source IP, $sp source port) and silently dropped:
there is no point replying to something that broken.
In-dialog traffic: has_totag and loose_route
if (has_totag()) {
if (loose_route()) {
t_on_reply("LOG_REPLY");
t_on_failure("LOG_INDLG_FAILURE");
route(RELAY);
} else if (is_method("ACK") && t_check_trans()) {
route(RELAY);
} else {
sl_send_reply("404", "Not here");
}
exit;
}A To header containing a tag means this request belongs to an existing
dialog — re-INVITEs, BYEs, REFERs, NOTIFYs. Mid-dialog messages carry a
Route header stamped by Record-Route when the dialog was set up;
loose_route() uses exactly that to send them where the dialog expects,
and the transaction module (tm) rewrites the destination so you don't have
to think about it. Before relaying we arm the callbacks — t_on_reply and
t_on_failure name the blocks that run when answers come back — then hand
off to route(RELAY).
ACK is the odd one out: it terminates the INVITE transaction but has no
transaction of its own, so it can't be loose_routed the same way.
t_check_trans() matches it against a known pending transaction — if there
isn't one, the ACK belongs to nobody and is dropped.
And the 404? If a mid-dialog request has no Route header and matches no
transaction, it's almost certainly stray replayed or misrouted traffic.
Answer "Not here" and be done.
CANCEL: only if there's something to cancel
if (is_method("CANCEL")) {
if (t_check_trans()) { route(RELAY); }
exit;
}A CANCEL aborts a ringing INVITE. It only makes sense if that INVITE exists
here — t_check_trans() verifies it, which also stops an attacker from
using your proxy to amplify fake CANCELs. Unknown CANCELs fall through the
if and hit the bare exit: swallowed silently.
OPTIONS: answer and move on
if (is_method("OPTIONS")) {
sl_send_reply("200", "OK");
exit;
}OPTIONS is the SIP ping. Providers and SBCs use it for keepalives and
health probes. Answering 200 OK statelessly costs nothing and keeps
upstream monitors happy — you almost never want to route it.
INVITE: dispatch by where it came from
if (is_method("INVITE")) {
# Outbound from the application server (loopback)
if (src_ip == 127.0.0.1) {
route(FROM_APP);
exit;
}
# Inbound — must be from a known trunk provider
if (!route(CHECK_ACL)) {
xlog("L_WARN", "BLOCK $si — not on ACL, to=$rU\n");
sl_send_reply("403", "Forbidden");
exit;
}
route(FROM_PROVIDER);
exit;
}This is the interesting split. Calls flow in two directions:
- Outbound — your application dials out. It talks to Kamailio on
loopback, so
src_ip == 127.0.0.1identifies it with zero auth overhead, androute(FROM_APP)handles rewriting before the trunk. - Inbound — a call arriving from the internet. Before you spend a single cycle on it, verify the sender is a provider you actually have a trunk with. That's the ACL — and it's a named route, which deserves its own section.
The catch-all
xlog("L_WARN", "405 $rm cid=$ci from=$si:$sp — method not handled\n");
sl_send_reply("405", "Method Not Allowed");Anything that survived all the branches above is a method this proxy simply
doesn't handle. Say so explicitly — and log it with $rm (method) and
$ci (Call-ID) so you can see what's knocking.
route[CHECK_ACL]: access control as a building block
The inbound INVITE branch calls route(CHECK_ACL) inside an if. Named
routes return a value, and that value is usable as a condition — which makes
routes like this composable: the check lives in one place, and every caller
reuses it.
Here's a typical implementation using the permissions module:
loadmodule "permissions.so"
modparam("permissions", "db_url", DB_URL)
route[CHECK_ACL] {
# Group 1 = trunk provider addresses (the "address" table)
if (allow_source_address(1)) {
return 1;
}
return -1;
}allow_source_address(1) checks the source IP against address group 1 — a
list maintained in the address table (or loaded from a file at startup).
return 1 makes route(CHECK_ACL) truthy; return -1 makes it false, so
the caller's if (!route(CHECK_ACL)) fires the 403.
Why a route, instead of inlining the check? Because "is this sender known" is asked in more than one place — INVITEs, REGISTERs, message routing — and you want one list, one lookup, one log line. When you add a second provider, you add a row to the table, not three edits across the config.
The poor-cousin version works too and is fine to start with:
route[CHECK_ACL] {
if ($si == "203.0.113.10" || $si == "198.51.100.7") {
return 1;
}
return -1;
}Hardcoded IPs mean a config reload for every change — the permissions table exists precisely so you don't do that.
The mental model
Reading a request_route is reading a funnel, ordered from cheapest to most
specific:
- Kill the impossible — loops, malformed messages.
- Handle the self-contained — in-dialog traffic, CANCEL, OPTIONS.
- Dispatch the real work — INVITE, split by where it comes from and whether the sender is trusted.
- Refuse everything else loudly.
Two last conventions make the whole file readable. exit ends processing
for this message entirely; return ends only the current route block and
hands control back to the caller — the same distinction as a process
aborting versus a function returning. And when you arm
t_on_reply/t_on_failure before relaying, later events have somewhere to
go: the transaction module remembers, even though the request long since
left the building.
That's really all a kamailio.cfg is: a very honest, very fast if-tree
about whose traffic you trust and where each message should go next.