Authentication and Identity: Verifying Who Is Talking

Learning Objectives

Pre-Reading Check — Identity Is the Second Line of Defense

Why is an assistant with an inbound endpoint (a webhook URL, email address, or chat handle) fundamentally different from a program that only makes outbound calls?

It is slower because it must wait for responses from external services.
Events arrive unsolicited, so it cannot control when — or, without verification, who — is calling it.
It cannot use HTTPS because inbound traffic is always plaintext.
It requires more memory to buffer inbound events.

Which pair of definitions correctly distinguishes authentication from authorization?

Authentication decides what a party may do; authorization proves who the party is.
Authentication proves who a party is; authorization decides what a proven party may do.
Both terms mean the same thing; the choice is stylistic.
Authentication maps external identities to internal principals; authorization encrypts the channel.

What is "identity mapping" in the context of a personal AI assistant?

Translating an external channel identity (e.g., a Slack user ID) into an internal principal the permission system understands.
Encrypting the sender's display name before logging it.
Assigning a random UUID to every inbound HTTP request.
Verifying that the webhook signature matches the shared secret.

The specific threat that authentication is designed to defend against is best described as:

Denial of service — flooding the endpoint with traffic.
Spoofing — an attacker pretending to be a legitimate source.
Buffer overflow — corrupting server memory with oversized payloads.
SQL injection — smuggling database commands through form fields.

Why should an assistant NOT treat an email's From: header or a chat display name as proof of the sender's identity?

Those fields are attacker-controllable and easily forged, so they provide no cryptographic proof.
Those fields are always encrypted and cannot be read by the assistant.
Email and chat providers strip those fields before delivery.
Those fields change format too often to parse reliably.

Identity Is the Second Line of Defense

Key Points

In earlier chapters we treated the content of an inbound message as hostile. But there is a prior question that vulnerable assistants never ask: who sent this message in the first place? If your assistant will act on a Slack event, a GitHub comment, or an email webhook, the very first thing it must establish is that the message genuinely came from the party it claims to be from.

A personal AI assistant that reads inbound messages is, by construction, reachable. It has an endpoint, and events arrive there unsolicited. That is fundamentally different from a program that only makes outbound calls: an API is request-response (your code asks, the other system answers), while a webhook is event-driven (the other system pushes an HTTP POST when something happens). Because events arrive on their own, you cannot control when — or, without verification, who — is calling you.

The consequence is blunt: without verification, anyone who guesses your endpoint URL can send fake events. An attacker who discovers the URL can inject forged events — a fake "new email from your boss," a spoofed "payment confirmed," a bogus chat command — and the assistant, holding the operator's credentials and tools, will dutifully act on them. Identity verification is the gate that decides whether a stranger gets to reach for that blast radius at all.

Authentication vs. authorization vs. identity mapping

Three related ideas are constantly confused. Authentication verifies identity — confirming who is making the request. Authorization determines what that authenticated party is permitted to do. A third step sits between them for assistants: identity mapping, translating an external channel identity (a Slack user ID, an email address) into an internal principal your permission system understands.

flowchart LR A["Inbound message
from external channel"] --> B{"Authentication
'Who is talking?'"} B -->|"Signature invalid"| R["Reject
(fail closed)"] B -->|"Signature valid"| C["Identity mapping
'Who is this, internally?'"] C -->|"Slack U123 → principal alice"| D{"Authorization
'What may they do?'"} D -->|"Permitted"| E["Execute action"] D -->|"Denied"| R
ConceptQuestion it answersExample for an assistantFailure if skipped
Authentication"Who is talking?"This webhook really came from Slack; this message really came from user U123Anyone who finds the URL can drive the bot
Authorization"What may they do?"User U123 may query calendars but not send wire transfersA real but low-privilege user triggers high-impact actions
Identity mapping"Who is this, internally?"Slack U123 → internal principal alice, tier "operator"Cross-channel confusion; wrong permissions applied

Threats: spoofing, impersonation, and forged webhooks

The threat that authentication defends against is spoofing — an attacker pretending to be a legitimate source. Webhook endpoints accept HTTP requests from the open internet, so they face spoofing risk. Signature verification ensures only the legitimate provider can trigger your handlers, preventing attackers from injecting fake events such as false login notifications or forged payment confirmations.

Analogy: a webhook endpoint without signature verification is like an office with an unlocked back door and a sign reading "Deliveries — walk right in." Legitimate couriers use it, but so does anyone who wanders by. A signed webhook is that same door with a lock whose key only the real courier company holds. The lock does not care how convincing the visitor looks — it only opens for the key.

Note carefully what is not trustworthy: display names, From: headers, and other easily-forged metadata. An email's From: field and a chat message's display name are attacker-controllable. Impersonation — sending a message that merely claims to be from your boss — is trivial when identity rests on such fields. Real authentication rests on a cryptographic proof the attacker cannot produce.

Visual animation — coming soon

Post-Reading Check — Identity Is the Second Line of Defense

Why is an assistant with an inbound endpoint (a webhook URL, email address, or chat handle) fundamentally different from a program that only makes outbound calls?

It is slower because it must wait for responses from external services.
Events arrive unsolicited, so it cannot control when — or, without verification, who — is calling it.
It cannot use HTTPS because inbound traffic is always plaintext.
It requires more memory to buffer inbound events.

Which pair of definitions correctly distinguishes authentication from authorization?

Authentication decides what a party may do; authorization proves who the party is.
Authentication proves who a party is; authorization decides what a proven party may do.
Both terms mean the same thing; the choice is stylistic.
Authentication maps external identities to internal principals; authorization encrypts the channel.

What is "identity mapping" in the context of a personal AI assistant?

Translating an external channel identity (e.g., a Slack user ID) into an internal principal the permission system understands.
Encrypting the sender's display name before logging it.
Assigning a random UUID to every inbound HTTP request.
Verifying that the webhook signature matches the shared secret.

The specific threat that authentication is designed to defend against is best described as:

Denial of service — flooding the endpoint with traffic.
Spoofing — an attacker pretending to be a legitimate source.
Buffer overflow — corrupting server memory with oversized payloads.
SQL injection — smuggling database commands through form fields.

Why should an assistant NOT treat an email's From: header or a chat display name as proof of the sender's identity?

Those fields are attacker-controllable and easily forged, so they provide no cryptographic proof.
Those fields are always encrypted and cannot be read by the assistant.
Email and chat providers strip those fields before delivery.
Those fields change format too often to parse reliably.
Pre-Reading Check — Verifying Webhook Signatures

When computing an HMAC signature for verification, why must you use the RAW request body exactly as received rather than re-serialized parsed JSON?

Parsed JSON is larger, so it makes the HMAC computation slower.
Re-parsing and re-serializing can change whitespace, key ordering, or encoding, invalidating the signature.
HMAC cannot process JSON objects, only raw byte buffers.
The provider only signs the raw body when the payload is not valid JSON.

Why must signature comparison use a constant-time function (e.g., hmac.compare_digest) instead of a plain ==?

A plain == returns false at the first mismatched byte, leaking timing that lets an attacker guess the signature byte-by-byte.
A plain == cannot compare hex strings, only integers.
Constant-time functions are faster on large inputs.
A plain == silently truncates long signatures.

A correctly-verified signature proves the request is authentic and untampered. What does it NOT prove, requiring an additional defense?

That the payload is valid JSON.
That the request is fresh — a captured, validly-signed request can still be replayed.
That the request used HTTPS.
That the shared secret is at least 32 bytes long.

In Stripe's scheme, the timestamp is included inside the signed payload (signing timestamp.body). Why is signing the timestamp important?

It compresses the payload so the signature is shorter.
It prevents an attacker from reusing an intercepted webhook with a modified timestamp, because altering the timestamp breaks the signature.
It lets the provider skip HTTPS since the timestamp encrypts the body.
It allows the server to avoid recomputing the HMAC at all.

Why is idempotency (tracking processed webhook IDs and skipping duplicates) often recommended as the primary replay defense?

Because it also gracefully handles legitimate provider retries, so a replayed webhook is processed only once even if received multiple times.
Because it removes the need for HMAC verification entirely.
Because it encrypts the webhook body at rest.
Because it lets clocks drift arbitrarily without breaking timestamp checks.

Verifying Webhook Signatures

Key Points

The standard mechanism providers use to prove a webhook is authentic is the HMAC signature. HMAC (Hash-based Message Authentication Code) combines the message with a shared secret — a key known only to the provider and your server — and runs both through a hash function such as SHA-256 to produce a fixed-size tag. The provider computes this tag over the request body and sends it in a header; your server, holding the same secret, recomputes it and checks that they match.

The security property that makes this work: the HMAC signature is tied to the specific payload — change one byte in the body and the signature is invalid; an attacker would need the shared secret to forge a valid one, and the secret never leaves your server. Because HMAC is symmetric, anyone with the secret can forge a valid signature, so the secret must be protected like any other credential.

sequenceDiagram participant P as Provider (e.g., GitHub) participant S as Your server Note over P,S: Both hold the same shared secret P->>P: tag = HMAC-SHA256(body, secret) P->>S: HTTP POST body + signature header S->>S: Read RAW body as bytes S->>S: expected = HMAC-SHA256(body, secret) S->>S: Constant-time compare(expected, sent) alt Signatures match S->>P: 200 OK, process event else Mismatch or missing S->>P: Reject (fail closed) end

Every HMAC-SHA256 webhook verifier follows the same four steps:

  1. Read the RAW request body in bytes, not parsed JSON.
  2. Compute HMAC-SHA256(body, secret) to produce 32 bytes.
  3. Hex-encode or base64-encode the 32 bytes to match the provider's format.
  4. Compare to the signature header using constant-time comparison.
flowchart TD A["Step 1: Read RAW request body as bytes
(never parsed / re-serialized JSON)"] --> B["Step 2: Compute HMAC-SHA256(body, secret)
→ 32 bytes"] B --> C["Step 3: Hex- or base64-encode
to match provider's format"] C --> D["Step 4: Constant-time compare
with signature header"] D --> E{"Match?"} E -->|"Yes"| F["Accept — request is authentic"] E -->|"No"| G["Reject"]

Why the raw body matters. This is the single most frequent verification bug. Always compute the HMAC over the raw body exactly as received; never compute HMAC against re-parsed/re-serialized JSON, because parsing and re-serialization can introduce subtle differences in whitespace, key ordering, or encoding that invalidate the signature. For example, Express's body-parser rebuilds the JSON, then your HMAC computes against the rebuilt string — which differs by even one whitespace character from the original. Capture the body as bytes before any JSON middleware runs:

Encoding and header quirks vary by provider. Most providers (Stripe, GitHub, Slack) use hexadecimal encoding, while Shopify and Square use base64; changing .digest('hex') to .digest('base64') (or the reverse) is a common copy-paste bug.

ProviderSignature headerEncodingNotable detail
GitHubX-Hub-Signature-256hexCarries a sha256= prefix that must be stripped before comparison
StripeStripe-SignaturehexSigns timestamp.body; header carries t= and v1=; SDK enforces 5-min tolerance
Slacksignature headerhexSigns payload with shared signing secret
Shopify / Squaresignature headerbase64Uses base64 rather than the hex default

A worked example: verifying a GitHub-style HMAC-SHA256 signature

Suppose GitHub delivers a webhook. It sends the header X-Hub-Signature-256: sha256=<hex>, computed as HMAC-SHA256 over the exact bytes of the request body using your configured secret. Here is a correct Python verifier, with the security-critical points annotated:

import hmac, hashlib

def verify_github_signature(raw_body: bytes, header_value: str, secret: str) -> bool:
    # header_value looks like "sha256=abc123..." — strip the scheme prefix.
    if not header_value or not header_value.startswith("sha256="):
        return False
    sent_sig = header_value.split("=", 1)[1]

    # Step 2: HMAC over the RAW body bytes (never re-serialized JSON).
    expected = hmac.new(
        secret.encode("utf-8"),
        raw_body,               # bytes exactly as received
        hashlib.sha256,
    ).hexdigest()               # Step 3: hex to match GitHub's format

    # Step 4: constant-time comparison — NOT ==
    return hmac.compare_digest(expected, sent_sig)

This mirrors the recommended sketch: hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() compared with hmac.compare_digest(). The Node.js equivalent uses crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('hex') to produce the expected signature, then crypto.timingSafeEqual() on Buffer versions of the two signatures.

Timing-safe comparison and constant-time verification

Step 4 deserves its own explanation. You must use a constant-time compare, never a plain === / ==. A regular string comparison returns false at the first mismatched byte, allowing an attacker to measure response times and guess the signature one byte at a time. A constant-time comparison always takes the same time regardless of where the mismatch occurs, defeating this attack.

Analogy: imagine a combination lock that clicks audibly the instant a wrong digit is entered. An attacker turns the first dial, listens for the click, and knows when the first digit is right because the click comes later. Digit by digit, they open the lock without ever knowing the whole combination. An ordinary == on strings behaves like that clicking lock. A constant-time comparison examines every byte regardless, so the timing reveals nothing.

LanguageConstant-time functionOperates on
Node.jscrypto.timingSafeEqual()Buffers of equal length
Pythonhmac.compare_digest()strings or bytes
RubyRack::Utils.secure_compare()strings

Two practical notes. First, crypto.timingSafeEqual() requires Buffers of equal length and throws otherwise, so guard the lengths first. Second, wherever a provider ships an official SDK, prefer it: never hand-roll HMAC verification when an official SDK exists. The SDK already handles encoding, header parsing, constant-time comparison, and replay protection.

Visual animation — coming soon

Timestamp checks and nonce/replay protection

Signature verification proves a request is authentic and untampered. It does not prove the request is fresh. Pure HMAC signature verification has a gap: an attacker who captures a legitimate, correctly-signed request can simply re-send ("replay") it, and signature verification alone will accept it because the signature is genuine. This is a replay attack, and closing it is a separate, required layer.

Analogy: a signed webhook is like a check with a valid signature. The signature proves you wrote it — but nothing stops someone who intercepts the check from cashing the same check twice. Replay protection is the "void after 5 minutes" stamp and the running list of check numbers you have already cashed.

There are three complementary defenses:

Two operational prerequisites make timestamp checks work: consumer and provider clocks must be synchronized (use NTP), and both parties must use the same timestamp format (UNIX epoch vs. RFC 3339). Beware that timestamp validation can conflict with provider retries after the tolerance window — tune the tolerance or rely on idempotency as the primary protection. Extending the earlier example with a timestamp check:

import time

MAX_AGE_SECONDS = 300  # 5-minute tolerance

def verify_with_replay_protection(raw_body, timestamp_str, sent_sig, secret, seen_ids, event_id):
    # 1. Freshness: reject stale requests.
    try:
        ts = int(timestamp_str)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - ts) > MAX_AGE_SECONDS:
        return False

    # 2. Sign timestamp.body so the timestamp cannot be forged.
    signed_payload = timestamp_str.encode("utf-8") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sent_sig):
        return False

    # 3. Idempotency / nonce: reject already-seen deliveries.
    if event_id in seen_ids:
        return False
    seen_ids.add(event_id)
    return True
flowchart TD A["Inbound signed request"] --> B{"Timestamp within
tolerance window?
(e.g., 5 min)"} B -->|"No — stale"| R["Reject"] B -->|"Yes"| C{"Signature over
'timestamp.body'
valid?"} C -->|"No"| R C -->|"Yes"| D{"event_id already
in seen store?"} D -->|"Yes — replay"| R D -->|"No"| E["Record event_id"] E --> F["Process once (idempotent)"]

The layered minimum is HTTPS + HMAC signatures + timestamp validation, rejecting requests older than your tolerance threshold.

Visual animation — coming soon

Post-Reading Check — Verifying Webhook Signatures

When computing an HMAC signature for verification, why must you use the RAW request body exactly as received rather than re-serialized parsed JSON?

Parsed JSON is larger, so it makes the HMAC computation slower.
Re-parsing and re-serializing can change whitespace, key ordering, or encoding, invalidating the signature.
HMAC cannot process JSON objects, only raw byte buffers.
The provider only signs the raw body when the payload is not valid JSON.

Why must signature comparison use a constant-time function (e.g., hmac.compare_digest) instead of a plain ==?

A plain == returns false at the first mismatched byte, leaking timing that lets an attacker guess the signature byte-by-byte.
A plain == cannot compare hex strings, only integers.
Constant-time functions are faster on large inputs.
A plain == silently truncates long signatures.

A correctly-verified signature proves the request is authentic and untampered. What does it NOT prove, requiring an additional defense?

That the payload is valid JSON.
That the request is fresh — a captured, validly-signed request can still be replayed.
That the request used HTTPS.
That the shared secret is at least 32 bytes long.

In Stripe's scheme, the timestamp is included inside the signed payload (signing timestamp.body). Why is signing the timestamp important?

It compresses the payload so the signature is shorter.
It prevents an attacker from reusing an intercepted webhook with a modified timestamp, because altering the timestamp breaks the signature.
It lets the provider skip HTTPS since the timestamp encrypts the body.
It allows the server to avoid recomputing the HMAC at all.

Why is idempotency (tracking processed webhook IDs and skipping duplicates) often recommended as the primary replay defense?

Because it also gracefully handles legitimate provider retries, so a replayed webhook is processed only once even if received multiple times.
Because it removes the need for HMAC verification entirely.
Because it encrypts the webhook body at rest.
Because it lets clocks drift arbitrarily without breaking timestamp checks.
Pre-Reading Check — Common Authentication Pitfalls

What is the most common way an assistant ships to production without authentication?

The provider forgets to send the signature header.
Verification was turned off for convenience during development (e.g., if DEBUG: skip_verification()) and never turned back on.
HTTPS certificates expire and disable the check automatically.
The shared secret rotates faster than the code can keep up.

What does it mean for signature verification to "fail closed"?

Reject the request when a signature is missing or unverifiable, rather than letting it through.
Accept the request but log a warning for later review.
Retry verification several times before giving up and accepting.
Close the network connection so the provider must resend.

Which fields is it safe for the assistant to trust when establishing a sender's identity?

Any custom HTTP header the client sets, such as X-User-Id.
Only fields inside the signed payload, verified against the shared secret.
The email From: header, since providers validate it.
The chat display name, since users rarely change it.

Because HMAC is symmetric, why is a leaked signing secret so catastrophic?

Anyone who obtains it can forge valid requests that pass signature, freshness, and nonce checks — indistinguishable from the real provider.
It forces the provider to switch to plaintext webhooks.
It only matters if the attacker also has the TLS private key.
It disables idempotency but leaves signature verification intact.

Which practice correctly protects a signing secret?

Hardcode it in source so it is versioned alongside the code.
Log full request headers, including the secret, for easier debugging.
Inject it at runtime from an environment variable or secret manager, never commit it, and rotate on suspected exposure.
Bake it into the container image so every deployment has it.

Common Authentication Pitfalls

Key Points

Skipping verification in development and shipping it to production

The most common way an assistant ships without authentication is that verification was turned off for convenience during development and never turned back on. Signature checks are annoying while iterating: you have to craft valid signatures for test payloads, keep clocks in sync, and juggle secrets. So developers add an if DEBUG: skip_verification() branch, or comment out the check, and the guardrail quietly evaporates by the time the code reaches production.

The fix is to make verification the default path, not an opt-in. Verification should be structurally impossible to bypass in production: fail closed (reject when a signature is missing or unverifiable rather than allowing through), and never gate the check behind an environment flag that could be misconfigured. If you need a local testing shortcut, use the provider's own tooling to generate genuinely-signed test events rather than disabling the check. The layered minimum — HTTPS + HMAC + timestamp validation — is the baseline for every environment that is reachable, not a production-only feature.

Trusting easily-forged headers and display names

A second class of failure is authenticating on the wrong thing. Signature verification exists precisely because the visible content of a request is forgeable: an attacker would need the shared secret to forge a valid signature — meaning anything not covered by the signature is fair game. A From: email header, a chat display name, a custom X-User-Id header, or a "verified" flag in the JSON body all travel with the request and can be set to anything. Trusting them is identity theater.

The rule is: only trust fields that are inside the signed payload, verified against the shared secret. The signature covers the raw body, so a sender_id inside that body — for a genuine, signature-passing request — is trustworthy; an arbitrary HTTP header outside the signed content is not. Concretely, do not let the assistant decide "this is from the operator, grant full access" based on a display name any sender can type. Establish the sender's identity from signed, provider-attested fields, then map that identity to a principal via allowlist mechanisms. Note too that the same HMAC signature that authenticates the source can carry an API key to identify which tenant the webhook belongs to for routing — but routing metadata is not the same as an authorization decision.

Visual animation — coming soon

Leaking or hardcoding signing secrets

Because HMAC is symmetric, the shared secret is the ability to forge valid requests. Anyone who obtains it can impersonate the provider perfectly — the signatures they produce will verify. The safety of the whole scheme rests on the fact that the secret never leaves your server. The common ways that promise gets broken:

The remedy is the discipline of secrets management: inject signing secrets at runtime from environment variables or a secret manager, never commit them, scan for accidental leaks, and rotate them if exposure is suspected. A leaked signing secret defeats every control in this chapter at once — the attacker's forged requests will pass signature verification, freshness checks, and nonce checks, because to your server they are indistinguishable from the real provider. Treat the signing secret as a crown-jewel credential.

Post-Reading Check — Common Authentication Pitfalls

What is the most common way an assistant ships to production without authentication?

The provider forgets to send the signature header.
Verification was turned off for convenience during development (e.g., if DEBUG: skip_verification()) and never turned back on.
HTTPS certificates expire and disable the check automatically.
The shared secret rotates faster than the code can keep up.

What does it mean for signature verification to "fail closed"?

Reject the request when a signature is missing or unverifiable, rather than letting it through.
Accept the request but log a warning for later review.
Retry verification several times before giving up and accepting.
Close the network connection so the provider must resend.

Which fields is it safe for the assistant to trust when establishing a sender's identity?

Any custom HTTP header the client sets, such as X-User-Id.
Only fields inside the signed payload, verified against the shared secret.
The email From: header, since providers validate it.
The chat display name, since users rarely change it.

Because HMAC is symmetric, why is a leaked signing secret so catastrophic?

Anyone who obtains it can forge valid requests that pass signature, freshness, and nonce checks — indistinguishable from the real provider.
It forces the provider to switch to plaintext webhooks.
It only matters if the attacker also has the TLS private key.
It disables idempotency but leaves signature verification intact.

Which practice correctly protects a signing secret?

Hardcode it in source so it is versioned alongside the code.
Log full request headers, including the secret, for easier debugging.
Inject it at runtime from an environment variable or secret manager, never commit it, and rotate on suspected exposure.
Bake it into the container image so every deployment has it.

Your Progress

Answer Explanations