> ## Documentation Index
> Fetch the complete documentation index at: https://docs.komaa.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Security

> HMAC v1 and v2 in the StandIn Python SDK: the handshake lane, the body lane, the replay windows, and what the SDK refuses.

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.

| Lane         | Signs                                                                  | Window | Header                   |
| ------------ | ---------------------------------------------------------------------- | ------ | ------------------------ |
| v1 handshake | `"{timestampMs}.{id}"`, where `id` is the channel name or the `callId` | 60 s   | `X-StandIn-Signature`    |
| v1 body      | `"{timestampMs}."` followed by the exact body bytes                    | 300 s  | `X-StandIn-Signature`    |
| v2 request   | `"{timestampMs}."` followed by `METHOD\npath\nsha256(body)`            | 60 s   | `X-StandIn-Signature-V2` |

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](/python-sdk/call-server#outbound-call-outcomes).

<Warning>
  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.
</Warning>

## 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.

```
X-StandIn-Timestamp: 1757500000000
X-StandIn-Signature: <hex of HMAC-SHA256(secret, "1757500000000.{callId}")>
```

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.

| Input                                               | Result                                                                                                                                                                                                                                                                                                                   |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Missing secret, timestamp or signature              | Rejected.                                                                                                                                                                                                                                                                                                                |
| A timestamp that is not ASCII decimal               | Rejected. `int()` alone also accepts underscores and Unicode digits, so the check is an explicit regex, matching the TypeScript SDK.                                                                                                                                                                                     |
| A timestamp outside the window, in either direction | Rejected.                                                                                                                                                                                                                                                                                                                |
| A signature containing a non-ASCII character        | Rejected. `compare_digest` raises `TypeError` on non-ASCII strings, and this path is reachable by anyone who can open a socket. Unguarded, a header of `"ünicode"` would turn an unauthenticated upgrade into a 500 rather than a 401, which is both a crash path and an oracle distinguishing "malformed" from "wrong". |
| A wrong signature                                   | Rejected, after a constant-time comparison.                                                                                                                                                                                                                                                                              |

Comparison is constant-time in every case that reaches it.

## Signing helpers

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

```python theme={null}
from standin import (
    SIGNATURE_HEADER,      # "X-StandIn-Signature"
    SIGNATURE_V2_HEADER,   # "X-StandIn-Signature-V2"
    TIMESTAMP_HEADER,      # "X-StandIn-Timestamp"
    REPLAY_WINDOW_MS,      # 60000
    CHAT_REPLAY_WINDOW_MS, # 300000
    canonical_request,
    now_ms,
    sign_body,
    sign_handshake,
    sign_request,
    verify_body,
    verify_handshake,
)
```

### sign\_handshake and verify\_handshake

```python theme={null}
sign_handshake(secret, timestamp_ms, handshake_id) -> str
verify_handshake(secret, timestamp, handshake_id, signature, current_ms=None) -> bool
```

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

```python theme={null}
sign_body(secret, timestamp_ms, raw_body) -> str
verify_body(secret, timestamp, raw_body, signature, current_ms=None, window_ms=300_000) -> bool
```

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.

<Warning>
  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.
</Warning>

```python theme={null}
import json

from standin import SIGNATURE_HEADER, TIMESTAMP_HEADER, now_ms, sign_body

raw_body = json.dumps(payload, separators=(",", ":"))   # serialize once
timestamp = str(now_ms())
headers = {
    TIMESTAMP_HEADER: timestamp,
    SIGNATURE_HEADER: sign_body(secret, timestamp, raw_body),
    "Content-Type": "application/json",
}
# send raw_body itself, not a re-serialized copy
```

### canonical\_request and sign\_request

```python theme={null}
canonical_request(method, path, raw_body) -> str   # "METHOD\npath\nsha256_hex(body)"
sign_request(secret, timestamp_ms, method, path, raw_body) -> str
```

v2, for HTTP control requests. `path` is the request path, without the origin and without the query string, matching the worker verifier.

```python theme={null}
from standin import SIGNATURE_V2_HEADER, TIMESTAMP_HEADER, now_ms, sign_request

timestamp = str(now_ms())
headers = {
    TIMESTAMP_HEADER: timestamp,
    SIGNATURE_V2_HEADER: sign_request(secret, timestamp, "POST", "/api/calls", raw_body),
}
```

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.

```python theme={null}
async def on_start(self, session: CallSession) -> None:
    caller = session.start.caller
    if caller.aad_id not in ALLOWED:
        await session.end("caller-not-allowed")
        return
```

<Warning>
  `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.
</Warning>

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](/python-sdk/call-handler#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.

```python theme={null}
from standin.fetch import assert_public_http_url, fetch_public_image, is_forbidden_ip
```

| Name                                                          | What it does                                                                                                                                                                                                                                      |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `await assert_public_http_url(url)`                           | Returns the URL, or raises `ValueError` naming the problem: a scheme that is not http or https, embedded credentials, no host, or a host that resolves into forbidden space.                                                                      |
| `is_forbidden_ip(address)`                                    | The address test on its own, for a check of your own. Private, loopback, link-local, carrier-grade NAT, multicast and reserved ranges. Anything it cannot parse is forbidden too, because something that is not an address cannot be proven safe. |
| `await fetch_public_image(url, max_bytes, timeout_ms=10_000)` | The whole guarded fetch, which is what the vision tools use. Returns `(bytes, mime)`, and raises `ValueError` with a reason you can hand straight back to the model that supplied the URL.                                                        |

<Warning>
  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.
</Warning>

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.

<Note>
  These three are top-level exports in the TypeScript SDK and live in `standin.fetch` here. See [Vision and the avatar](/python-sdk/vision#fetching-an-image-a-model-chose) for the image case.
</Note>

## 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

<CardGroup cols={2}>
  <Card title="CallServer" icon="server" href="/python-sdk/call-server">
    Where the handshake and the replay guard are enforced.
  </Card>

  <Card title="Chat" icon="comments" href="/python-sdk/chat">
    The outbound lane, where your worker is the one signing.
  </Card>
</CardGroup>
