Skip to main content

Webhooks

WIP

AuthWebhookDeliverer broadcasts AuthEngine events to HTTP endpoints with HMAC-signed bodies, exponential backoff, SSRF guards, and dead-letter sinks.

AuthWebhookDeliverer fans out duck-auth events to one or more HTTPS endpoints. Every payload is HMAC-signed so receivers can verify authenticity, oversized bodies are dropped at 1 MiB, and the SSRF guard rejects loopback / private / link-local / cloud-metadata target hosts at construction.

import { AuthWebhookDeliverer } from '@gentleduck/auth/core'

Setup

const webhooks = new AuthWebhookDeliverer({
  endpoints: [
    {
      url: 'https://hooks.example.com/duck-auth',
      secret: process.env.WEBHOOK_SECRET!,
      events: ['signin.success', 'signout', 'recovery.password.completed'],
    },
  ],
  maxAttempts: 5,        // default
  backoffMs: 500,        // base; doubles each retry (0.5s, 1s, 2s, 4s, 8s)
  timeoutMs: 5_000,
  deadLetter: dlqSink,
})

auth.events.subscribe('*', (event, payload) => {
  void webhooks.deliverOne(event, payload)
})

Endpoint contract

Each IEndpoint row carries the URL, secret, and an event-filter list. Pass events: '*' to subscribe an endpoint to every event the bus emits. Default signatureHeader is X-Duck-Signature; override per endpoint when integrating with a downstream tool that expects a specific name.

{
  url: 'https://hooks.example.com/duck-auth',
  secret: 'shh',
  events: ['signin.success', 'signout'],
  signatureHeader: 'X-Hub-Signature-256',
  id: 'analytics-pipe',         // for UI / audit
}

Endpoints are validated at construction:

  • URLs must be https:// unless allowInsecure: true (dev only).
  • SSRF guard rejects loopback / RFC 1918 / link-local / 169.254.169.254 / NAT64 / IPv6 mapped-IPv4 of the above.
  • Secret must be a non-empty string.

Payload shape

{
  "event": "signin.success",
  "timestamp": 1716387200000,
  "payload": { /* event-specific */ }
}

The whole envelope is JSON. Body length is capped at 1 MiB; oversized payloads are dropped (one console.error per drop, no retry) so a runaway event source cannot multiply outbound load across every endpoint.

Signature

The signature header carries sha256=<hex> where <hex> is

HMAC-SHA256(secret, `${timestamp}.${body}`)

timestamp is the same millis embedded in the payload. Sign with authSignWebhookBody on either side, verify with authVerifyWebhookSignature - both exported from the same module:

import { authSignWebhookBody, authVerifyWebhookSignature } from '@gentleduck/auth/core'

// Sender side (typically you do not call this - AuthWebhookDeliverer does)
const sig = authSignWebhookBody(secret, body, timestamp)

// Receiver side
if (!authVerifyWebhookSignature(secret, body, signatureHeader, { timestamp, toleranceMs: 5 * 60_000 })) {
  res.status(401).end()
  return
}

toleranceMs defaults to 5 minutes; verifier returns false for timestamps outside the window so a replayed request from yesterday cannot fire your handler.

Retry policy

Failed deliveries retry with exponential backoff up to maxAttempts.

AttemptDefault delay
10 ms
2500 ms
31_000 ms
42_000 ms
54_000 ms
6dead letter

5xx and network errors retry. 4xx is permanent - the deliverer dead- letters immediately on 4xx so a busted endpoint configuration doesn't burn the retry budget.

Dead letter sink

IDeadLetterSink.put({ event, payload, attempts, lastError, endpoint }) is called for every permanently failed delivery. Wire to your queue of choice (SQS, Redis Stream, plain file) so an operator can replay later.

const sink: AuthWebhookDeliverer.IDeadLetterSink = {
  async put(entry) {
    await redis.xadd('auth-webhook-dlq', '*', 'data', JSON.stringify(entry))
  },
}

Entries carry the original event + payload + the URL + the last error message, so a downstream tool has everything needed to retry or alert.

Per-event filtering

Receivers usually only care about a subset of events. Use the endpoint's events array to filter before dispatch:

{
  url: 'https://hooks.example.com/duck-auth/security',
  secret: '...',
  events: [
    'signin.failed',
    'recovery.password.completed',
    'mfa.enrolled',
    'mfa.removed',
    'session.revoked',
  ],
}

Set this to a closed list (no '*') when the receiver only handles specific event names.

See also

  • Events - the full event list and payload shapes.
  • OpenTelemetry - traces + spans for the same paths.