Daraja.Callback.Security (daraja v0.1.0)

Copy Markdown View Source

Optional verification helpers for inbound M-PESA webhook requests.

Safaricom Daraja callbacks are unsigned JSON POSTs. Authenticity must be enforced by the host application—typically via HTTPS, a shared secret embedded in the callback URL, IP allowlisting, idempotency on transaction IDs, and reconciliation against outbound API calls or Safaricom query/status APIs.

This module does not prove a payment occurred; it only helps reject requests that clearly did not come through your expected transport controls.

IP allowlist provenance

The built-in defaults are community-documented Safaricom Kenya callback egress addresses widely cited in M-Pesa integrator guides and open-source packages. They are not fetched from the live Daraja API and may change without notice.

Defaults combine:

  • CIDR subnets 196.201.212.0/24, 196.201.213.0/24, and 196.201.214.0/24 (Safaricom 196.201.x.x address space)
  • An explicit host list matching commonly published production callback IPs

Verify the current list with Safaricom support or your own production logs, then override via application config:

config :daraja,
  callback_cidrs: ["196.201.214.0/24", "196.201.213.0/24", "196.201.212.0/24"],
  callback_hosts: ["196.201.214.200", "196.201.212.127"]

Per-request overrides are also supported through verify/1 and safaricom_ip?/2.

IP allowlisting is intended for production. Sandbox callbacks (for example via ngrok) typically will not match these ranges—disable check_ip in dev.

Example (Phoenix)

with :ok <-
       Daraja.Callback.Security.verify(
         ip: conn.remote_ip,
         check_ip: true,
         shared_secret: Application.fetch_env!(:my_app, :mpesa_callback_secret),
         provided_secret: conn.params["token"]
       ),
     {:ok, callback} <- Daraja.Express.Callback.parse(payload),
     :ok <- Daraja.Callback.Guard.ensure_fresh(callback.checkout_request_id) do
  # ...fulfil order...
  json(conn, Daraja.Express.Callback.accept())
else
  {:error, :untrusted_ip} -> send_resp(conn, 403, "Forbidden")
  {:error, :invalid_secret} -> send_resp(conn, 403, "Forbidden")
  {:error, :invalid_callback, _} -> send_resp(conn, 400, "Bad Request")
  {:error, :duplicate} -> json(conn, Daraja.Express.Callback.accept())
end

Register callback URLs with a secret query parameter, for example https://example.com/mpesa/callback?token=your-secret, and pass the same value to provided_secret.

Summary

Functions

Returns the active CIDR and host allowlists as a map.

Returns the configured callback CIDR allowlist.

Returns the configured explicit callback host allowlist.

Built-in CIDR defaults shipped with the library.

Built-in explicit host defaults shipped with the library.

Returns true when ip matches the active allowlist.

Constant-time comparison of callback URL secrets.

Runs enabled verification checks. Skips any check whose inputs are omitted.

Types

allowlist()

@type allowlist() :: %{cidrs: [String.t()], hosts: [String.t()]}

verify_error()

@type verify_error() :: :untrusted_ip | :invalid_secret | :invalid_ip

Functions

allowlist()

@spec allowlist() :: allowlist()

Returns the active CIDR and host allowlists as a map.

callback_cidrs()

@spec callback_cidrs() :: [String.t()]

Returns the configured callback CIDR allowlist.

Reads :callback_cidrs from config :daraja, ... when set, otherwise default_cidrs/0.

callback_hosts()

@spec callback_hosts() :: [String.t()]

Returns the configured explicit callback host allowlist.

Reads :callback_hosts from config :daraja, ... when set, otherwise known_callback_hosts/0.

default_cidrs()

@spec default_cidrs() :: [String.t()]

Built-in CIDR defaults shipped with the library.

known_callback_hosts()

@spec known_callback_hosts() :: [String.t()]

Built-in explicit host defaults shipped with the library.

These mirror commonly published Safaricom production callback IPs cited in integrator documentation. Prefer callback_hosts/0 at runtime.

safaricom_ip?(ip, allowlist_override \\ [])

@spec safaricom_ip?(term(), keyword() | [String.t()]) :: boolean()

Returns true when ip matches the active allowlist.

ip may be an IPv4 tuple, or a string parseable by :inet.parse_address/1. Only IPv4 matching is supported.

When the second argument is a plain list of CIDR strings (no cidr/host keys), only those CIDRs are checked—useful in tests. Otherwise pass [] or a keyword list with optional :cidrs and :hosts overrides.

shared_secret_valid?(provided, expected)

@spec shared_secret_valid?(term(), term()) :: boolean()

Constant-time comparison of callback URL secrets.

Returns true when both values are binaries and equal. Returns false when either value is nil, not a binary, or they differ.

verify(opts)

@spec verify(keyword()) :: :ok | {:error, verify_error()}

Runs enabled verification checks. Skips any check whose inputs are omitted.

Options:

  • :ip — client address as an IPv4/IPv6 tuple or string (e.g. conn.remote_ip)
  • :check_ip — when true, requires :ip to match :cidrs and/or :hosts
  • :cidrs — CIDR strings for this request; defaults to callback_cidrs/0
  • :hosts — explicit IPv4 strings for this request; defaults to callback_hosts/0
  • :shared_secret — expected secret (e.g. from app config)
  • :provided_secret — secret from the callback URL query/path

Returns :ok or {:error, reason}.