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 it | Whose data | |
|---|---|---|
| Server APIs | Your own server code: notify.send(), notify.inbox.*, notify.deliveries.list(), explain() | Whatever recipientId you pass. The caller is trusted |
| Handler routes | Browsers, through the HTTP handler and the React hooks | Only 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 arecipientId,tenantId, orworkspaceIdin 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 a500with no details.
Isolation
Every handler query is filtered by the identity’s recipient and scope. There is no option that turns this off.
| Surface | Outside the identity's scope |
|---|---|
| Inbox list, unread count, preferences | Not included |
| Mark read, archive, unarchive, delete | 403 for an item that belongs to someone else or another scope, 404 if it does not exist |
| Realtime stream | Events for other recipients or scopes are never sent |
GET /deliveries | Needs 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.
| Permission | Grants |
|---|---|
deliveries.list | The caller's own delivery records |
admin | Delivery 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.
GETshows a confirmation page;POSTunsubscribes. APOSTwith anOriginheader from another site is refused; one with noOrigin, as mail clients send, is allowed.- A missing or forged token gets a
400page, and the route is404unless 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
httpandhttpsURLs, 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
httprequests 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
actionUrlmust be a relative path or anhttp,https, ormailtoURL; anything else, such asjavascript:, is dropped. - Line breaks are stripped from rendered webhook headers.
- Fields listed in a definition’s
redactarray are replaced with"[REDACTED]"in hook payloads. The stored notification keeps the full payload for your server code.
Hardening
| Handler option | What it does |
|---|---|
requestRateLimit | Caps requests per identity ({ max, windowMs }), answering 429. In memory, per server instance |
cors | One allowed origin. Avoid "*" if you use cookies |
protectNotifications | Requires sign-in for GET /notifications, which is public by default |
debug | Adds 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.