BackProduction

Security model

Which APIs trust the caller, which are safe for the browser, and how identity and tenants are kept apart.

Two Kinds of API

Who calls itWhose data
Server APIsYour own server code: notify.send(), notify.inbox.*, notify.deliveries.list(), explain()Whatever recipientId you pass. The caller is trusted
Handler routesBrowsers, through the HTTP handler and the React hooksOnly the user identify() returns

Never expose a server API to the browser directly. Anything a client can reach should go through the handler, which resolves the user itself.

Identity

identify() gets the request and returns the user, as a recipient id string or an object with a scope and permissions. Return null for an unauthenticated request, never a default user.

identify: async (request) => {
	const session = await auth(request)
	if (!session) return null // 401
	return {
		recipientId: session.user.id,
		tenantId: session.organizationId,
		permissions: session.user.role === "admin" ? ["admin"] : [],
	}
}
  • The React SDK has no way to pass a recipientId, and a recipientId, tenantId, or workspaceId in a request body or query string is ignored.
  • Ids from identify() must be non-empty strings; anything else is treated as unauthenticated.
  • If identify() throws, the request gets a 500 with no details.

Isolation

Every handler query is filtered by the identity’s recipient and scope. There is no option that turns this off.

SurfaceOutside the identity's scope
Inbox list, unread count, preferencesNot included
Mark read, archive, unarchive, delete403 for an item that belongs to someone else or another scope, 404 if it does not exist
Realtime streamEvents for other recipients or scopes are never sent
GET /deliveriesNeeds a permission; see below

Server APIs are not scoped for you, because you pass the recipient and scope yourself. See Multi-tenancy.

Permissions

Only GET /deliveries needs a permission. Every other route is limited to the user’s own data by identify() alone.

PermissionGrants
deliveries.listThe caller's own delivery records
adminDelivery records for any recipient in the admin’s own scope, chosen with ?recipientId=

Grant them in the permissions array from identify(), or pass authorize(context, permission) to decide per request. When authorize is set, it replaces the permissions array. Records from this route leave out to, subject, and body; the server API returns them in full.

Unsubscribe Links

Unsubscribe links sit in emails that get forwarded and cached, and they have to work without a login. The token in each one is its only authentication:

  • It is an HMAC-SHA256 over the recipient, notification, tenant, and workspace, checked in constant time.
  • It cannot be moved to another user or notification, and only ever turns off that notification’s email.
  • It never expires, because RFC 8058 one-click links must keep working.
  • GET shows a confirmation page; POST unsubscribes. A POST with an Origin header from another site is refused; one with no Origin, as mail clients send, is allowed.
  • A missing or forged token gets a 400 page, and the route is 404 unless you configure a secret.

The secret is unsubscribe.secret in createNotifyKit() and unsubscribeSecret in the handler, and they must match. Generate at least 32 random bytes and keep it in your secret manager.

Webhooks

Outgoing

With webhookProvider({ secret }), every webhook carries an x-notifykit-signature header that receivers check with verifyWebhookSignature(); see Providers. NotifyKit also protects your own network from webhook URLs:

  • Only http and https URLs, without credentials in them, are allowed.
  • Addresses on private or internal networks (localhost, 10.x, 172.16-31.x, 192.168.x, link-local, IPv6 private ranges, .internal, .local, cloud metadata) are blocked, as are numeric IP tricks like hex or octal.
  • Hostnames are resolved first and blocked if any address is private. Plain http requests are then sent to the address that was checked, so DNS cannot change between the check and the request.
  • Redirects are refused.

Incoming

To receive delivery events from your provider (bounces, complaints), give the handler a verifier per provider. It exposes POST /webhooks/:provider, answers 401 when your verifier returns false, and passes verified events to onWebhookEvent:

createRouteHandler({
	notifykit: notify,
	identify,
	webhooks: {
		resend: (headers, rawBody) => verifyResendWebhook(headers, rawBody, secret),
	},
	onWebhookEvent: async (provider, event) => {
		// update your records, suppress bouncing addresses, ...
	},
})

Content

  • Template values are HTML-escaped in inbox titles and bodies, and in email bodies unless the channel sets html: false.
  • An inbox actionUrl must be a relative path or an http, https, or mailto URL; anything else, such as javascript:, is dropped.
  • Line breaks are stripped from rendered webhook headers.
  • Fields listed in a definition’s redact array are replaced with "[REDACTED]" in hook payloads. The stored notification keeps the full payload for your server code.

Hardening

Handler optionWhat it does
requestRateLimitCaps requests per identity ({ max, windowMs }), answering 429. In memory, per server instance
corsOne allowed origin. Avoid "*" if you use cookies
protectNotificationsRequires sign-in for GET /notifications, which is public by default
debugAdds a fix hint to error responses. Leave it off in production; hints can name ids

Request bodies over 1 MB are rejected. The unsubscribe and notification-list routes are not covered by requestRateLimit, so rate-limit them by IP at your proxy.

Reporting

Report vulnerabilities privately through GitHub’s private reporting, not in a public issue. Include the package and version, the impact, and steps to reproduce; you should hear back within seven days. Authentication, tenant isolation, unsubscribe tokens, webhook signatures, SSRF, and data exposure are the areas that matter most. Fixes go to the latest release, so upgrade before reporting.