Back to Blog
Security

mTLS for Webhooks: Proving Who Is Calling, Not Just What They Sent

Your HMAC signature proves the payload was not tampered with. It says nothing about who opened the connection. mTLS does, and Shopify just made receivers care by rotating the certificate under everyone's feet.

WebhookVault Team · Webhook Infrastructure Experts12 min read
Fiber-optic patch panel in a server rack with white and yellow cables plugged into rows of green-connectored ports, several marked with small numbered labels

Your Signature Proves the Payload. It Says Nothing About the Caller.

An HMAC signature answers one question: were these exact bytes produced by someone holding the shared secret. It is a good answer. But notice what it leaves open. It does not tell you who opened the TCP connection, whether the TLS handshake came from the provider or from a box in a coffee shop replaying a captured request, or whether the caller is even allowed to talk to your endpoint at all. The signature travels inside the request. Anyone who can copy the request can copy the signature with it.

For most webhooks that gap does not matter, because you pair the signature with a timestamp and idempotency and move on. For a payments endpoint moving real money, it starts to matter a lot. That is why Shopify quietly made a whole class of receivers care about the transport layer this summer, and why a chunk of Payment Apps woke up on June 15 to find their validation logic rejecting the very requests they were built to accept.

What mTLS Actually Adds to a Webhook

Ordinary TLS is one-sided. The server presents a certificate, the client checks it against a trusted certificate authority, and the client stays anonymous. That is fine for a browser hitting a shop. It is backwards for a webhook, where the party you want to authenticate is the one connecting to you.

Mutual TLS closes that gap. During the handshake the client also presents a certificate, and your server refuses to complete the connection unless that certificate chains to a CA you trust. The authentication happens before a single byte of the request body is read. An attacker who scraped your webhook URL, copied a valid signed payload, and fired it at you never gets far enough to deliver it, because they cannot produce a client certificate signed by the provider's CA. The connection dies in the handshake.

That is the important shift. HMAC authenticates the message. mTLS authenticates the peer. They sit at different layers and they fail in different ways, which is exactly why the interesting question is not "which one" but "what happens when you run both and one of them changes."

Shopify Rotated the Certificate Under Everyone's Feet

Here is the concrete event that makes this current. Shopify Payments Apps use mTLS in the direction that matters for receivers: Shopify presents a client certificate to the app's endpoints, and the app verifies "that the client initiating the request is Shopify." On June 15, 2026 a new certificate went into effect, and the certificate it replaced expired on July 24, 2026. Two certificates, a five-week overlap, one hard cutoff.

Shopify's own changelog split the world in two. If your app validates by trusting the certificate authority, "no action is required. The new certificate is signed by the same CA." If your app "checks the Common Name (CN) or other certificate-specific fields," you had to "update your validation logic to accept the new certificate" before June 15. Read that again, because it is the whole lesson compressed into two sentences. The teams that pinned a specific leaf certificate had to ship a code change on a deadline set by someone else. The teams that trusted the CA did nothing and never noticed.

This is the same failure shape as IP allowlisting, where a provider rotates the thing you hardcoded and your endpoint starts rejecting legitimate traffic with no attacker in sight. A control that breaks quietly on the provider's schedule is not a security win, it is a scheduled outage you have not been told the date of.

Pin the CA, Not the Leaf

The fix is architectural, not a patch you apply once per rotation. Configure your trust store to accept the provider's CA, and validate that incoming client certificates chain to it. Do not compare the leaf certificate's fingerprint against a constant. Do not string-match the full CN and reject anything else. The leaf is designed to rotate; the CA is designed to be stable.

If you genuinely need to check identity beyond "signed by the right CA," check the coarse, stable fields the provider commits to keeping. An organizational name is more durable than a serial number. But every field you assert on is a field the provider can change, so assert on as few as you can defend. The most robust posture is: trust the CA, confirm the certificate is currently valid, and stop there.

# Terminate mTLS at the edge and require a client cert
# that chains to Shopify's CA bundle. No leaf pinning.
server {
    listen 443 ssl;
    server_name payments.yourapp.com;

    ssl_client_certificate /etc/ssl/shopify-ca-bundle.pem;
    ssl_verify_client on;
    ssl_verify_depth 2;

    location /webhooks/shopify {
        # Fail closed: reject if the handshake did not verify.
        if ($ssl_client_verify != SUCCESS) { return 403; }

        proxy_set_header X-Client-Cert-Verify $ssl_client_verify;
        proxy_set_header X-Client-Cert-DN     $ssl_client_s_dn;
        proxy_pass http://app_upstream;
    }
}

The Card-Deposit Mandate Runs the Other Direction

To see how the two directions differ, look at the second change Shopify shipped. Starting October 15, 2026, the card-deposit endpoint at https://checkout-mtls.pci.shopifyinc.com/sessions, reached through the GraphQL Admin API mutations customerPaymentMethodCreditCardCreate and customerPaymentMethodCreditCardUpdate, requires that "every request to it must present a Shopify-issued mTLS client certificate." Here your app is the client and Shopify is the server. The certificate is issued to you, manually, by emailing Shopify's partnerships address with your API client ID and a technical contact. First certificates are signed by hand and "take a few days."

After that first issuance you rotate self-serve through Shopify's Certificate Signing Service before the one-year TTL runs out, with "no Shopify involvement." And then the sentence every operator should tattoo somewhere: "A missed rotation stops your deposits, so monitor certificate expiry as part of your standard observability." A webhook that stops arriving at least fails loudly on the sender's dashboard. A client certificate that silently expires just turns your outbound calls into connection errors at 2 a.m., and the only alert you get is the one you built yourself.

Where the TLS Actually Terminates

This is the trap that eats a day of debugging. mTLS is verified during the TLS handshake, and in almost every real deployment TLS is terminated somewhere in front of your application code: a load balancer, an ingress controller, a CDN, a reverse proxy. By the time the request reaches your handler, the connection is plain HTTP on an internal network, and the client certificate is gone. Your application sees a request that verified perfectly and has no idea it did.

So you have two jobs, not one. First, terminate mTLS at the edge and configure it to fail closed. Reject the handshake outright when the client certificate does not verify, instead of passing the request through with a flag your app is trusted to check. Second, forward the verification result and whatever certificate fields you care about as internal headers, and make sure nothing outside your trust boundary can spoof those headers. If an attacker can reach your app directly and set X-Client-Cert-Verify: SUCCESS themselves, you have built an authentication bypass with extra steps. This is the same posture problem as reading X-Forwarded-For from an untrusted hop; the header is only as trustworthy as the proxy that set it.

Managed platforms hide this decision, which is worse, not better. If your CDN silently strips or does not forward the client certificate, mTLS was never actually enforced end to end even though the handshake succeeded. Verify it deliberately.

mTLS and HMAC Are Not Substitutes

It is tempting to treat mTLS as the upgrade that lets you delete the signature check. Do not. They authenticate different things and they cover for each other's blind spots.

mTLS proves the peer at connection time, but it says nothing about the bytes once TLS terminates. If a compromised proxy inside your own boundary rewrites the payload after the handshake, mTLS is blind to it. The connection was authentic, the content is not. HMAC covers exactly that: it binds the signature to the request body, so a mutated payload fails verification no matter how legitimate the connection was. This is the same reason transforming payloads at a gateway breaks signatures. Any rewrite after signing invalidates the proof.

Run backwards, HMAC proves the payload but not the connection. A replayed request carries a valid signature. mTLS refuses the replay at the handshake if the attacker cannot present a client certificate. Together they give you a caller you trust and a payload you trust. Either one alone leaves a gap that the other closes, which is the actual definition of defense in depth rather than the phrase people paste into security pages. Keep your existing signature verification exactly as it is and add mTLS in front of it.

Reading the Client Certificate in Your Handler

When you do need to inspect the certificate rather than just trust the CA, keep the inspection coarse and keep it after the edge has already verified the chain. The handler's job is to read the forwarded, trusted result, not to re-implement chain validation in application code.

import type { Request, Response, NextFunction } from 'express';

// These headers are only trustworthy because the edge proxy
// set them AND the app is not reachable from outside the mesh.
export function requireShopifyClientCert(
  req: Request,
  res: Response,
  next: NextFunction,
) {
  const verified = req.header('x-client-cert-verify') === 'SUCCESS';
  if (!verified) {
    return res.status(403).json({ error: 'client certificate not verified' });
  }

  // Optional, coarse identity assertion on a stable field only.
  // Do NOT pin a serial number or a full leaf fingerprint here.
  const dn = req.header('x-client-cert-dn') ?? '';
  if (!dn.includes('O=Shopify')) {
    return res.status(403).json({ error: 'unexpected client identity' });
  }

  next();
}

Notice what is missing: no fingerprint comparison, no hardcoded serial, no exact CN equality. Every one of those would have turned June 15 into an incident. The middleware trusts the edge for chain verification and asserts only on an organizational field that Shopify is unlikely to churn.

Rotation Is an Observability Problem, Not a Security One

Once mTLS is in place, the failure you will actually hit is not an attacker. It is an expiry date. Client certificates have a TTL, one year in Shopify's case, and the moment one lapses, the affected direction goes dark. Inbound, Shopify's certificate to you rotates on Shopify's calendar, and if you pinned the leaf you break. Outbound, your certificate to Shopify rotates on yours, and if you forget you stop depositing.

Treat certificate expiry as a first-class metric with the same seriousness you give disk space or a domain's TLS cert. Export days-to-expiry for every mTLS certificate in play, alert well before the deadline, and rehearse the rotation before it is urgent. The self-serve signing service exists precisely so rotation is boring; the danger is that boring things fall off the roadmap until they are an outage. If you already track secret rotation for HMAC keys, certificate expiry belongs on the same board.

When mTLS Is Worth the Operational Weight

Be honest about the cost. mTLS adds a trust store to maintain, an edge configuration to get right, header-forwarding you have to secure, and a rotation clock that fails your service if you ignore it. For a low-stakes product.updated webhook that already carries a signature, that weight buys almost nothing. The signature plus a timestamp is proportionate.

The calculus flips when the connection itself is a target: payment deposits, card data, endpoints where an authenticated caller is part of your compliance story rather than just an authenticated payload. That is exactly the ground Shopify is standing on, and it is why the mandate lands on card deposits rather than on ordinary storefront events. If a regulator or a payment network expects you to prove who opened the connection, a signature will not do it and mTLS will. Everywhere else, add it when the endpoint's blast radius justifies the clock you are agreeing to wind.

Frequently asked questions

Does mTLS replace HMAC signature verification for webhooks? No. mTLS authenticates the peer that opened the connection, and it verifies during the TLS handshake before your application reads the body. HMAC authenticates the exact bytes of the payload. A replayed request still carries a valid signature, and a payload rewritten after TLS terminates still arrives on an authentic connection. Each one closes the gap the other leaves open, so run both on high-value endpoints rather than trading one for the other.

Why did trusting the CA instead of the leaf certificate matter so much for Shopify's June 2026 rotation? Because a leaf certificate is built to rotate and a CA is built to stay put. Shopify replaced its client certificate on June 15, 2026 and let the old one expire on July 24. Apps that pinned the leaf fingerprint or exact-matched certificate-specific fields had to ship a code change before the cutoff or start rejecting real Shopify requests. Apps that validated against the same unchanged CA needed no action at all. Trust the authority, confirm validity, and assert on as few certificate fields as you can defend.

Where does mTLS get verified if a load balancer terminates TLS? At the load balancer, ingress, or reverse proxy that terminates the handshake, not in your application. By the time the request reaches your code it is usually plain HTTP on an internal hop and the client certificate is gone. Configure the edge to fail closed when the certificate does not verify, then forward the verification result as an internal header, and make sure nothing outside your trust boundary can set that header. A spoofable verification header is an authentication bypass wearing a badge.

What happens if my mTLS client certificate expires? The affected direction stops working with no attacker involved. Shopify's card-deposit certificates carry a one-year TTL and rotate self-serve through its Certificate Signing Service, but a missed rotation stops your deposits outright. Unlike a webhook that fails visibly on the sender's dashboard, an expired client certificate just turns outbound calls into connection errors. Export days-to-expiry as a metric, alert ahead of the deadline, and rehearse rotation before it is urgent.

Is mTLS worth adding to every webhook endpoint? Rarely. It brings a trust store, edge configuration, secured header-forwarding, and a rotation clock that takes your service down if ignored. For an ordinary signed event that weight buys little. Reserve it for endpoints where the connection itself is the target: payment deposits, card data, or cases where proving who called is part of a compliance requirement rather than a nice-to-have.

Related posts