§ 2.3  Module 2 — Naming the Session: SDP, SIP, and Offer/Answer

SDP Anatomy, Line by Line

SDP is purely a format for session description — it does not incorporate a transport protocol.— RFC 4566, July 2006

By the end of this lesson

  • Parse a real browser offer into a structured object (sections, codecs, directions, groupings)
  • Resolve dynamic payload types via rtpmap and chain RTX to primaries via fmtp apt=
  • Explain the WebRTC SDP dialect: BUNDLE, rtcp-mux, mid, msid, extmap, rid/simulcast, b= lines
  • Explain why m-line port 9 and c=IN IP4 0.0.0.0 are normal, not bugs

Historical note

A modern browser offer is an archaeological dig, and every stratum is dateable. The top lines — v=, o=, s=, t=, c= — were designed for the Mbone, the experimental multicast backbone that carried its first IETF audiocast from San Diego in March 1992 (Steve Casner and Steve Deering at the console). Van Jacobson and Steve McCanne's sd tool at Lawrence Berkeley Lab, and later Mark Handley's sdr at UCL, displayed those announcements like a program guide; Handley and Jacobson formalized the format as SDP in RFC 2327 (April 1998), with SAP (RFC 2974, October 2000) as the multicast flyer-distribution service. A flyer describes; it cannot negotiate — fine for broadcast, useless for a phone call. So the middle stratum is the VoIP era: SIP (RFC 3261, June 2002) carried SDP bodies, and Jonathan Rosenberg and Henning Schulzrinne's offer/answer model (RFC 3264, June 2002) conscripted the flyer into a two-way bargaining format — m= sections, a=rtpmap, direction attributes. The newest stratum is WebRTC's: after Google bought Global IP Solutions in 2010 and open-sourced WebRTC in 2011, the JSEP authors — Justin Uberti and Cullen Jennings, eventually RFC 8829 (January 2021) — chose, over the loud objections of the ORTC camp (Robin Raymond of Hookflash, backed by Microsoft, from 2013), to keep SDP and graft onto it everything a browser needs: BUNDLE, mid, msid, fingerprint, ice-ufrag, rid. Reading an offer line by line is therefore reading thirty years of compromise, and this lesson accounts for every line.

Here is the specimen we will dissect: an offer produced by createOffer() on a Chromium RTCPeerConnection with one audio transceiver, one video transceiver, and one data channel — abridged (a real one runs 100–150 lines; the elisions are marked) but with every kind of line represented.

v=0
o=- 4611731400430051336 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1 2
a=extmap-allow-mixed
a=msid-semantic: WMS

m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:4ZcD
a=ice-pwd:2/1muCWoOi3uLifh0NuRHlLH
a=ice-options:trickle
a=fingerprint:sha-256 7B:8B:F0:65:5F:78:E2:51:3B:AC:6F:F3:3F:46:1B:35:
                      DC:B8:5F:64:1A:24:C2:43:F0:A1:58:D0:A1:2C:19:08
a=setup:actpass
a=mid:0
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=sendrecv
a=msid:- 7a742619-2d5e-4e3b-a4e6-31b1a5c0d5f1
a=rtcp-mux
a=rtpmap:111 opus/48000/2
a=rtcp-fb:111 transport-cc
a=fmtp:111 minptime=10;useinbandfec=1
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
  ⋮  (more codecs: red, G722, CN, telephone-event)
a=ssrc:3735928559 cname:oV8rP2nQ4xL7mZ1c

m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99
c=IN IP4 0.0.0.0
  ⋮  (ice-ufrag, fingerprint, setup: identical to section 0)
a=mid:1
a=sendrecv
a=rtcp-mux
a=rtpmap:96 VP8/90000
a=rtcp-fb:96 ccm fir
a=rtcp-fb:96 nack
a=rtcp-fb:96 nack pli
a=rtcp-fb:96 goog-remb
a=rtcp-fb:96 transport-cc
a=rtpmap:97 rtx/90000
a=fmtp:97 apt=96
a=rtpmap:98 H264/90000
a=fmtp:98 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f
a=rtpmap:99 rtx/90000
a=fmtp:99 apt=98

m=application 9 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 0.0.0.0
  ⋮
a=mid:2
a=sctp-port:5000
a=max-message-size:262144

Listing 2.3 — An abridged Chromium offer: audio, video, and a data channel. Blank lines are inserted here for legibility only; real SDP has none, and every line ends in CRLF.

2.3.1The session header: five lines from the Mbone

Everything above the first m= line is the session level; it applies to the whole description. The first four lines are pure 1990s. v=0 is the protocol version — still zero after twenty-seven years, because the format never versioned; it accreted. o= is the origin line, six fields: username, session id, session version, network type, address type, address. Browsers put - for the username, a random 64-bit integer for the session id, and — the one field that still works for a living — a session version that increments every time renegotiation produces a new description. When you diff two offers from the same connection, that third field tells you which is newer. The address is a shrugged-off 127.0.0.1: in 1998 this line identified an announcement's source machine for cache replacement in directory tools; in a browser it identifies nothing.

s= is the session name — the actual text on the 1992 flyer, where sdr displayed titles like "IETF audio." Browsers emit -, the standard's prescribed non-value. t=0 0 is start and stop time as NTP timestamps: a concert flyer needed showtimes; a call that starts now and ends whenever is "0 0," unbounded. These lines are mandatory, in this order — SDP's fixed line order (which Lesson 2.1's validator enforced) was chosen so that strict one-pass parsers could reject garbage announcements from strangers.

Then the first WebRTC graft appears, at session level because it spans sections: a=group:BUNDLE 0 1 2. Hold that thought for §2.3.5 — but notice its position. Attribute lines (a=) are SDP's extension mechanism, and the same syntax appears at both levels. An attribute before the first m= line belongs to the session; after, it belongs to the most recent m= section. Binding attributes to the wrong scope is the classic first bug in every SDP parser ever written, which is why Problem 1 tests for it explicitly.

2.3.2The m= section, and why port 9 is not a bug

An m= line opens a media section and carries four things: a media type (audio, video, or application), a transport port, a protocol identifier, and a list of format tokens. Read the protocol identifier right to left, like a protocol stack: UDP/TLS/RTP/SAVPF means RTP under the secure audio-visual profile with feedback (SAVPF, RFC 5124 — SRTP from RFC 3711 plus the AVPF feedback profile of RFC 4585), keyed by TLS — that is, DTLS-SRTP, RFC 5764 — riding on UDP. The data channel's UDP/DTLS/SCTP with the single format token webrtc-datachannel (RFC 8841) declares SCTP over DTLS instead; note that format tokens are not always numbers, which is why a robust parser keeps them as strings.

Now the two "bugs" every newcomer files. The port is 9 and the connection line says c=IN IP4 0.0.0.0. In 1998 these fields were the whole point — the flyer said send RTP here. In WebRTC they are deliberate dummies, because addresses are no longer SDP's job: ICE will gather candidate addresses and test pairs of them, and the winning pair — not the m= or c= line — determines where media actually flows. An offer created before gathering finishes (which, under trickle ICE, is always) has nothing truthful to write, so RFC 8840 prescribes port 9 — the discard service, RFC 863, Jon Postel's /dev/null of the ARPANET — and the unspecified address 0.0.0.0 as placeholders. The companion a=rtcp:9 IN IP4 0.0.0.0 (RFC 3605) is the same shrug for RTCP's port.

There is a fossil beneath the fossil: before RFC 3264 standardized a=inactive, setting c=0.0.0.0 was the folk idiom for putting a call on hold. So 0.0.0.0 has meant, in successive decades, "nowhere," "on hold," and "trickle ICE will tell you." The line's meaning is dated by the layer it sits in — archaeology again.

Interactive 3D instrument

SDP, line by line — order is the index, BUNDLE is the transport

A 3D instrument you drive yourself, one variable at a time. It needs JavaScript and WebGL, so it is not shown in this static copy of the page.

Session-level and media-level attributes both exist, and media winsspec

a=ice-ufrag, a=ice-pwd, a=fingerprint, and a=setup are legal at session level or media level, with media level overriding. Browsers have differed on where they write them, and with BUNDLE the values are identical across m-lines anyway — until they are not. A munger that rewrites "the fingerprint" with a single non-global regex changes one occurrence and leaves the rest, producing a description that parses cleanly and then fails DTLS on some m-lines.

What to do

If you must rewrite these, rewrite every occurrence at both levels. In practice, modifying a=fingerprint, a=ice-ufrag, or a=ice-pwd is rejected by setLocalDescription in current Chrome, which is a feature — treat the rejection as a signal that your approach is wrong.

2.3.3rtpmap, fmtp, rtcp-fb: the codec table

The format tokens on the audio m= line — 111 63 9 0 8 13 110 126 — are RTP payload types, the 7-bit number every RTP packet carries to say what its payload is. Numbers below 96 are static: RFC 3551 (Schulzrinne and Casner, July 2003) assigned them once, globally — 0 is PCMU, 8 is PCMA, 9 is G.722, 13 is comfort noise. A 1996-era endpoint could send payload type 0 with no negotiation at all. But the registry had 96 slots and the world had more codecs, so numbers 96–127 are dynamic: they mean nothing until an a=rtpmap line binds them, per session, to a codec name, clock rate, and (for audio) channel count. a=rtpmap:111 opus/48000/2 is such a binding — payload type 111 is Opus here, but could be anything in the next call. RFC 7587 (June 2015) fixes Opus's RTP clock at 48000 and its channel count at 2 regardless of what the microphone captures; the codec resamples internally.

a=fmtp lines carry format-specific parameters as semicolon-separated key=value pairs: minptime=10;useinbandfec=1 asks Opus for at-least-10 ms packets with in-band forward error correction; H.264's profile-level-id=42001f;packetization-mode=1 pins down which flavor of H.264 (the codec wars live inside that hex string). And a=rtcp-fb lines enumerate which RTCP feedback messages (RFC 4585) the sender of this description is prepared to handle for that payload type: nack (retransmit request), nack pli (picture loss indication — note the two-word value, distinct from bare nack), ccm fir, goog-remb and transport-cc for bandwidth estimation. Module 6 spends whole lessons on what these do; today you only need to parse them.

The subtlest entries are the RTX ones. a=rtpmap:97 rtx/90000 plus a=fmtp:97 apt=96 says: payload type 97 is not a codec at all but a retransmission stream (RFC 4588, July 2006) whose apt — associated payload type — is 96. Retransmitted VP8 packets arrive under PT 97 so the receiver's loss statistics for the primary stream stay honest. Every video codec in the offer gets its own RTX shadow: 97 chains to 96 (VP8), 99 chains to 98 (H.264). Resolving those chains means comparing the apt value — a string, because everything in SDP is a string — against payload-type keys that your parser probably stored as numbers. That string-versus-number comparison is the second classic bug, and Problem 2 is built to catch it.

Check your understanding

Auto-graded check

3 auto-graded questions with an explanation for every wrong answer. Requires JavaScript. (m2-l3-check1)

2.3.4Direction attributes

Each media section carries at most one of four direction attributes — a=sendrecv, a=sendonly, a=recvonly, a=inactive — written from the describing party's point of view: sendonly in an offer means "I intend to send and not receive" on that section. If no direction appears, sendrecv is implied (RFC 8866 §6.7). The syntax is 1998; the semantics are 2002 — RFC 3264 defines how an answer's direction must respond to an offer's (a sendonly offer meets a recvonly answer), and that negotiation dance is Lesson 2.4's subject. In the browser API these four values surface directly as RTCRtpTransceiver.direction: call pc.addTransceiver('video', {direction: 'recvonly'}) and the string a=recvonly appears in your next offer. Hold music, one-way broadcast, camera-off states — all of them are, on the wire, just this one attribute changing across a renegotiation.

b=AS is kilobits, setParameters is bits, and one of them will bite youapi

b=AS:1000 means 1 Mbit/s — the units are kilobits per second including IP overhead. RTCRtpEncodingParameters.maxBitrate is bits per second, so maxBitrate: 1000 is 1 kbit/s and produces a black smear rather than an error. Firefox honours b=TIAS (RFC 3890, bits per second) where Chrome historically honoured b=AS, so the same munge behaves differently across browsers. Opus's maxaveragebitrate fmtp parameter is also in bits per second, up to 510000.

What to do

Stop munging b= lines and use sender.setParameters() with encodings[0].maxBitrate in bits per second. Write the unit into the variable name. If you see a video stream pinned at an absurdly low quality with no qualityLimitationReason, suspect a factor-of-1000 error before suspecting the network.

2.3.5The WebRTC dialect

Everything in this section postdates 2009 and exists because browsers needed things a flyer never did. BUNDLE first: classic SDP assumed each m= section got its own transport — its own port, its own ICE negotiation, its own DTLS handshake. Three sections would mean three NAT traversals, tripling setup time and firewall exposure. a=group:BUNDLE 0 1 2 (RFC 8843, on the grouping framework of RFC 5888) instead declares that the sections named 0, 1, 2 share one transport. The names are mid values — a=mid:0 tags each section with a media identifier — and once audio, video, and data share a single encrypted UDP flow, the receiver needs help demultiplexing: RTP header extensions carry the mid on the wire, and payload-type numbers must not collide across bundled sections. This is why the ice-ufrag/fingerprint block repeats identically in every section of Listing 2.3: each section states its transport parameters, and under BUNDLE they all state the same ones.

rtcp-mux (RFC 5761, April 2010) is the same consolidation one layer down: historically RTP flowed on an even port and RTCP on the odd port above it; a=rtcp-mux multiplexes both onto one port, distinguishable because RTCP packet types occupy a number range no RTP payload type uses. WebRTC requires it. Between BUNDLE and rtcp-mux, what would have been six ports in 2005 is one.

msid (RFC 8830) ties the wire back to the JavaScript object model: a=msid:- 7a742619-… names the MediaStream (here -, none) and MediaStreamTrack that this section carries, so the remote side can reassemble your stream/track structure in its ontrack events. ssrc lines (RFC 5576) declare the 32-bit synchronization source identifiers the RTP packets will carry, with a cname binding them to one RTCP endpoint. extmap (RFC 8285) negotiates RTP header extensions by mapping small IDs to URIs — ssrc-audio-level (RFC 6464) lets an SFU rank speakers without decrypting audio; transport-wide-cc numbers packets across all streams for congestion control. rid (RFC 8851) and a=simulcast (RFC 8853) declare restricted encodings — the three-resolution ladder of simulcast, Module 7's territory.

Finally the bandwidth lines, the dialect's unit trap. b=AS:2500 caps a section at 2500 kilobits per second ("application-specific" bandwidth, from RFC 2327, including lower-layer overhead); b=TIAS:2500000 (RFC 3890, September 2004) says the same thing in bits per second, excluding transport overhead. Chromium historically honored AS, Firefox TIAS; a dashboard that confuses them is wrong by a factor of a thousand.

Check your understanding

Auto-graded check

3 auto-graded questions with an explanation for every wrong answer. Requires JavaScript. (m2-l3-check2)

2.3.6Pointers outward: fingerprint, setup, ice-ufrag

Four lines in every section are promissory notes on the next two modules. a=ice-ufrag and a=ice-pwd (RFC 8839) are the username fragment and password that ICE connectivity checks will carry, so a peer can prove that a STUN binding request really comes from the party it is negotiating with — ufrag at least 4 characters, password at least 22. a=ice-options:trickle (RFC 8840) announces that candidates will dribble in after the offer rather than being baked into it. Module 3 is entirely about that machinery.

a=fingerprint:sha-256 … (RFC 8122) is a hash of the self-signed certificate this peer will present in the DTLS handshake. The signaling channel — which you, per JSEP, provide and presumably trust — vouches for the fingerprint; the DTLS handshake then proves the peer holds the matching private key. That is how WebRTC gets authenticated encryption without certificate authorities, and Module 4 walks the handshake. a=setup:actpass (syntax from RFC 4145, usage from RFC 5763) states who will initiate that handshake: an offer always says actpass — "you choose" — and the answerer picks active (it dials) or passive (it listens). Note the inversion worth savoring: the ICE controlling role, the DTLS client role, and the signaling offerer are three separate hats, and they need not be worn by the same peer.

A common error

"Now that I can read SDP, I can edit it — grab the string from createOffer(), tweak a line, and pass it to setLocalDescription()." This is called SDP munging, and it is not a supported API. RFC 8829 (JSEP) is explicit that a munged description may be rejected by setLocalDescription, and browsers tighten validation over time, so munges rot. Nearly everything munging was once used for now has a real API: RTCRtpTransceiver.setCodecPreferences() to reorder or drop codecs, RTCRtpSender.setParameters() for bitrate caps and encoding parameters, constructor options for the rest. Read SDP fluently; write it never.

v=0 · o=- 4611…336 2 · s=- · t=0 0 a=group:BUNDLE 0 1 2 a=extmap-allow-mixed · a=msid-semantic: WMS m=audio 9 UDP/TLS/RTP/SAVPF 111 0 8 … a=mid:0 · a=rtpmap:111 opus/48000/2 a=ice-ufrag · a=fingerprint · a=setup m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 a=mid:1 · a=rtpmap:96 VP8/90000 a=rtpmap:97 rtx/90000 · a=fmtp:97 apt=96 m=application 9 UDP/DTLS/SCTP webrtc-datachannel a=mid:2 · a=sctp-port:5000 1992–1998 · the Mbone flyer v= o= s= t= c= — RFC 2327 2002–2006 · the SIP era m=, rtpmap, fmtp, directions — RFC 3264 / 4566 2011–2021 · WebRTC grafts BUNDLE, mid, msid, fingerprint — RFC 8829 / 8843 / 8830 a=group:BUNDLE binds mids 0, 1, 2 to one ICE + DTLS transport
Figure 2.3 — One offer, three decades. Attributes above the first m= line are session-level; each m= line opens a section that owns every line after it. The dashed spine at left is BUNDLE collapsing three would-be transports into one.
Problems for § 2.3Check your understanding

Two parsers, graded first against a canned offer and then against a genuine createOffer() from a live RTCPeerConnection in your own browser.

Problem 1 asks for a structural parser. Return {session: {attrs: []}, media: []}, where session.attrs holds every session-level attribute value (the text after a=) and each media entry is {type, port, fmts, attrs, direction, mid}: type the media type string, port a number, fmts the format tokens as strings, attrs the section's attribute values, direction one of the four direction tokens (defaulting to "sendrecv" when absent), and mid the value of a=mid or null.

Lab 1

Parse your own offer

Graded in the browser against 4 assertions; the editor and harness require JavaScript.

Problem 2 builds the codec table. Return an object keyed by payload type, each entry {name, clock, channels, fmtp, rtcpFb}: name and clock (a number) from a=rtpmap, channels a number or null when the rtpmap has no third field, fmtp an object of the key=value pairs from a=fmtp (values kept as strings; empty object if none), and rtcpFb an array of the full feedback values from a=rtcp-fb (so "nack" and "nack pli" are two distinct entries). For RTX entries, additionally set an apt field containing the associated payload type as a number.

Lab 2

The codec map

Graded in the browser against 4 assertions; the editor and harness require JavaScript.

Field quirks

What the specifications say, and what the browsers actually do, are two different documents. These are the divergences that cost production teams their weekends.

The c= line is vestigialapi

In WebRTC the connection address is determined entirely by ICE. Chrome writes c=IN IP4 0.0.0.0 in an offer generated before gathering, and something that looks like a real address afterwards, and neither is authoritative. Tooling that parses c= to display "connected to 203.0.113.5" is displaying a fiction, and IP-based allowlisting driven off c= allowlists the wrong thing.

What to do

Read the selected candidate pair from getStats() for the real addresses. For allowlisting, allowlist your TURN servers and force iceTransportPolicy: 'relay' — that is the only architecture where the peer address is knowable in advance.

MID values are opaque strings, and they get recycledbrowser

Chrome writes numeric MIDs (0, 1, 2) matching m-line order at first negotiation; older Firefox wrote sdparta_0 style tokens. Neither is guaranteed, and when a rejected m-line is recycled for a new track the MID changes while the index stays put. MIDs also travel on the wire in the MID RTP header extension (RFC 8843), so long MID strings cost bytes in every single packet.

What to do

Treat MIDs as opaque, never parse them as integers, and never assume mid === String(index). If you control an SFU, key on MID but re-read it after every renegotiation rather than caching it for the session.

The Impossible Call · Module 2, Lesson 3