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
- An assistant with an inbound endpoint is reachable by the entire internet; if it trusts every request, it hands its blast radius to strangers.
- Authentication answers "who is talking?"; authorization answers "what may they do?"; identity mapping translates an external identity into an internal principal.
- The threat authentication defends against is spoofing — an attacker pretending to be a legitimate source (a forged webhook, a falsified sender).
- Display names,
From: headers, and similar metadata are attacker-controllable and must never be treated as proof of identity.
- Authentication is the second line of defense: injection defenses assume a message got in; authentication tries to stop illegitimate messages from getting in at all.
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
| Concept | Question it answers | Example for an assistant | Failure if skipped |
| Authentication | "Who is talking?" | This webhook really came from Slack; this message really came from user U123 | Anyone who finds the URL can drive the bot |
| Authorization | "What may they do?" | User U123 may query calendars but not send wire transfers | A 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
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.
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
- An HMAC signature combines the request body with a shared secret and a hash (SHA-256) to produce a tag only a secret-holder can forge or verify.
- The four-step recipe: read the RAW body as bytes, compute HMAC-SHA256 with the secret, encode to the provider's format (hex or base64), and compare with a constant-time function.
- Computing HMAC over re-serialized JSON is the most frequent verification bug; capture the body as bytes before any JSON middleware runs.
- Timing-safe comparison defeats a timing side-channel; never use a plain
== and never roll your own comparator.
- A valid signature proves authenticity, not freshness — add replay protection via signed-timestamp tolerance windows, nonces, and idempotency.
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:
- Read the RAW request body in bytes, not parsed JSON.
- Compute HMAC-SHA256(body, secret) to produce 32 bytes.
- Hex-encode or base64-encode the 32 bytes to match the provider's format.
- 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:
- Node.js:
express.raw({ type: "application/json" }) captures the body as a Buffer.
- Python:
await request.body() (FastAPI) or request.get_data() (Flask).
- Ruby:
request.raw_post before Rails deserializes.
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.
| Provider | Signature header | Encoding | Notable detail |
| GitHub | X-Hub-Signature-256 | hex | Carries a sha256= prefix that must be stripped before comparison |
| Stripe | Stripe-Signature | hex | Signs timestamp.body; header carries t= and v1=; SDK enforces 5-min tolerance |
| Slack | signature header | hex | Signs payload with shared signing secret |
| Shopify / Square | signature header | base64 | Uses 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.
| Language | Constant-time function | Operates on |
| Node.js | crypto.timingSafeEqual() | Buffers of equal length |
| Python | hmac.compare_digest() | strings or bytes |
| Ruby | Rack::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:
- Signed timestamp + tolerance window. The provider includes a timestamp inside the signed payload, so it cannot be altered without breaking the signature. The consumer checks it falls within an acceptable window (typically 3–5 minutes) of current server time. This is Stripe's scheme: it signs the payload as
timestamp.body and rejects anything older than five minutes.
- Nonces (one-time identifiers). Some providers include a unique nonce or notification ID with each delivery. By storing which nonces you have processed and rejecting reuse, you catch replays even within the tolerance window.
- Idempotency (the primary defense). Make the endpoint idempotent by tracking processed webhook IDs and skipping duplicates. A replayed webhook is then processed only once, and this also handles legitimate provider retries.
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
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.
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
- The most common failure is disabling verification "for convenience" in development and shipping it; make verification the default path and structurally unbypassable.
- Fail closed: reject requests with missing or unverifiable signatures rather than letting them through.
- Only trust fields inside the signed payload; display names,
From: headers, and arbitrary HTTP headers are identity theater.
- Because HMAC is symmetric, a leaked shared secret lets an attacker forge requests that pass every control in this chapter at once.
- Protect the signing secret as a crown-jewel credential: inject at runtime, never commit or log it, and rotate on suspected exposure.
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:
- Hardcoding the secret in source and committing it to version control, where it lives in git history forever even after removal.
- Logging the secret — or logging full request headers/bodies that contain it — into log aggregators many people can read.
- Baking it into a container image or client-side bundle, shipping it to anywhere the artifact is distributed.
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.
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.