Skip to main content
Every StandIn connection is authenticated with HMAC-SHA256 over your connection secret. There are two lanes, they sign different things, and they have different replay windows. They are both real, and neither is a fix for the other. The timestamp always travels in X-StandIn-Timestamp, in milliseconds. Signatures are lowercase hex.

Why the lanes differ

A WebSocket upgrade has no body worth signing. What identifies it is the thing in the URL, so the signature binds the callId (inbound) or the channel name (outbound), and the window is tight: handshakes are dialed and answered immediately, so 60 seconds is already generous, and anything older is a replay. A POST has a body, and the body is the whole message. Signing anything less would leave the payload unauthenticated, so the body lane signs the exact transmitted bytes, and the window is 300 seconds because chat relay retries are legitimately delayed. That 300 seconds belongs to the v1 body lane and to nothing else. v2 goes further and binds the method and the path as well as the body hash, so a signature captured for one endpoint cannot be replayed against another. Control requests should use v2. The only v2 verifier in this SDK is the worker’s own outcome route, the one on_call_outcome opens, and it checks freshness in the 60-second handshake window rather than the relay window: a control request is issued and answered immediately, like a dial. See Outbound call outcomes.
Do not present one lane as a fix for the other. A body signature does not protect a WebSocket upgrade, and a handshake signature does not authenticate a POST body. Sign the thing you are actually sending.

The inbound call handshake

StandIn dials wss://<your-host>/msteams/calling/{callId} with two headers. CallServer verifies them before the upgrade completes, and you write no code for this.
The callId that is signed is the URL path segment. That matters later: when session.start arrives with a callId, the server checks it against the authenticated path and closes the call if they disagree. A body that disagrees with the path is either a bug or an attempt to ride one call’s signature into another’s session. It is also why session.call_id is trustworthy: it is the value the HMAC signed, not something a caller supplied.

The replay guard

Verifying the signature is not enough on its own. A correctly signed upgrade, captured and replayed inside the 60-second window, would otherwise open a second socket. So CallServer keeps each accepted handshake as single-use. Three details in that guard are worth knowing, because each closes a real gap: The fingerprint uses the normalized signature. Verification accepts case and whitespace variants, so keying the cache on the raw header would let the same capture replay once per casing. Entries age from the signing timestamp, never from arrival. Verification accepts a timestamp up to the window in the future, so an entry aged from arrival could be pruned while its signature was still valid, reopening the exact replay the guard exists to close. Aged from the signing time, an entry lives precisely as long as the signature does. Pruning is throttled by time, not triggered by size. Rebuilding the map once it passes a watermark would make every later request O(n). Only correctly signed, not-yet-seen handshakes reach the cache at all, since a bad signature is rejected earlier and a replay returns before it, so the map tracks StandIn’s real call rate rather than attacker traffic. A replay gets 401 handshake already used.

What the SDK refuses

verify_handshake and verify_body fail closed. They return False, never raise, and never tell the caller which part was wrong. Comparison is constant-time in every case that reaches it.

Signing helpers

All of these prepare signatures. None of them send anything.

sign_handshake and verify_handshake

The WebSocket lane, 60-second window. handshake_id is the callId when StandIn dials you, and the channel name when your worker dials the chat channel. ChatChannel signs with it on your behalf; CallServer verifies with it on your behalf.

sign_body and verify_body

The chat POST lane, 300-second window. The window is a parameter with a default rather than a constant, so the 300 seconds follows the body lane and does not leak into anything else. It signs "{timestampMs}." followed by the exact body bytes.
Serialize the body once and send those same bytes. Parsing and re-serializing JSON can change whitespace, key order or Unicode escaping, and then the signature you sent no longer matches the bytes you sent.

canonical_request and sign_request

v2, for HTTP control requests. path is the request path, without the origin and without the query string, matching the worker verifier.
The raw body covers every field, including tenantId, which is what selects the organisation to act on. Binding method and path as well means a signature for one endpoint is useless against another.

What the secret does not decide

The handshake proves the dial came from StandIn. That is all it proves. Authentication is not authorization. verify_handshake says the socket is genuine; it says nothing about whether this particular caller may be answered. Who is allowed to reach your agent is deployment policy, and it lives in your handler.
caller.aad_id is None for guest and anonymous callers. Write the test as caller.aad_id not in ALLOWED, never as if caller.aad_id and caller.aad_id not in ALLOWED, which fails open for exactly the callers you least want to admit: an anonymous caller can never match an allowlist, so a guard that skips the check when the id is missing admits everyone it cannot identify.
The TypeScript SDK ships inbound-policy helpers in the package (isInboundCallAllowed and its companions). A Python deployment writes the check in its handler instead, which is the snippet above. See Refusing a call.

The outbound fetch guard

An agent that can show the caller a picture will sooner or later be handed a URL by its own model, and that model is steered by whoever is on the call. So the URL is untrusted input wearing a trusted costume, and a plain GET of it is a server-side request forgery: 169.254.169.254 is cloud credentials, 127.0.0.1 is whatever else you run, and 10.0.0.0/8 is the rest of your network.
The first and the third are coroutines, because the guard has to resolve the host before it can judge it. Awaiting them is the check; calling one without await builds a coroutine, validates nothing, and is truthy, so a guard written as if assert_public_http_url(url): passes every URL there is.
The subtle half is the rebind. Validating a hostname resolves it once, and the resolution the HTTP client does a moment later can answer differently, so the address the socket actually connects to is re-checked against the same rules. One redirect hop is followed, because image CDNs habitually redirect to the real asset, and the target goes through the whole guard again rather than being trusted for having come from a host that passed. Link-local is on the list by name, because that is where cloud metadata lives.
These three are top-level exports in the TypeScript SDK and live in standin.fetch here. See Vision and the avatar for the image case.

The generated wire builders

standin.protocol is generated from the schema and carries the frame builders and parsers themselves: audio_frame, session_end, pong, assistant_cancel, parse_message, parse_session_start and decode_pcm. assistant_cancel is the only one re-exported from the standin barrel; the rest are from standin.protocol import .... They are public because the wire is public, and they are what the shared conformance vectors assert across both languages. A handler should still not reach for them: CallServer owns the sequence number and the outbound timeline, and a hand-built frame is how those two stop agreeing.

Operational notes

Clock skew is authentication. Both windows are absolute differences, so a worker whose clock drifts more than 60 seconds stops accepting calls. Run NTP. Terminate TLS at your ingress. StandIn dials wss://, and the handshake headers are only as private as the transport carrying them. The secret is the credential. It must byte-match the portal value. Keep it in the environment, never in a source file or an image layer, and rotate it in the portal if it is ever printed in a log. Bind narrowly when you can. STANDIN_HOST=127.0.0.1 keeps the listener off every other interface when a local tunnel is the only thing that should reach it. The upgrade is authenticated either way, but there is no reason to offer more surface. Capacity is checked before crypto. A flood cannot make the worker spend CPU verifying signatures for calls it was never going to accept.

Conformance

Both SDKs are generated from protocol/schema.yaml and checked against shared vectors in protocol/conformance.json, the HMAC cases included. The Python and TypeScript implementations sign identically, byte for byte, which is what lets a Python worker and a TypeScript worker share one StandIn identity. The protocol modules are generated and drift-gated, never hand-edited.

Next

CallServer

Where the handshake and the replay guard are enforced.

Chat

The outbound lane, where your worker is the one signing.