Security model
NotifyKit separates server-only APIs (called from trusted application code) from client-safe handler routes (exposed to browsers). This page documents the full security contract.
Server (trusted)
notify.send(), notify.explain(), notify.deliveries.list() — full access. Caller provides recipientId directly.
Client (untrusted)
React SDK and REST routes — identity resolved via identify(). Cannot specify recipientId. Scoped to the authenticated user.
Tenant isolation
Every query is scoped by identity. Cross-tenant reads return empty, writes return 403.
Webhook signatures
HMAC-SHA256 on outgoing webhooks, timing-safe verification on incoming ones.
Payload redaction
PII fields are masked in logs, hooks, and timeline — never leaked to external surfaces.
Unsubscribe HMAC
Signed links that work without auth sessions. Bound to recipient, unforgeable, no expiry.
Rate limiting & CORS
Per-identity request caps and origin restrictions prevent abuse from untrusted clients.
Secret rotation
Dual-secret pattern for zero-downtime key rotation without breaking outstanding links.
Client-safe routes
Browser clients never pass recipientId. Every client-facing route resolves the current user through your identify() callback. Any recipientId, tenantId, or workspaceId in the request body is ignored.
createHandler(notify, {
identify: async (request) => {
const session = await auth(request)
if (!session) return null // → 401
return {
recipientId: session.user.id,
tenantId: session.orgId,
workspaceId: session.workspaceId,
}
},
})Tenant & workspace isolation
When identify() returns a scope, NotifyKit applies it to every database query in the request. Cross-tenant reads and writes return 403 Forbidden or filter to an empty set.
| Surface | Isolation | Cross-tenant attempt |
|---|---|---|
| Inbox reads & mutations | Scoped by tenantId | 403 or empty set |
| Preference reads & writes | Scoped by tenantId | 403 or empty set |
| Delivery logs | Scoped by tenantId | Filtered to empty |
| Realtime SSE events | Scoped by tenantId | Never receives foreign events |
| Unsubscribe links | Bound to (recipient, tenant) | HMAC fails → 401 |
How isolation is enforced
Your function extracts recipientId + tenantId from the request's session.
Every DB query appends WHERE tenant_id = :tenantId AND recipient_id = :recipientId. Not optional — you can't skip this.
Mark-read, archive, delete check the item belongs to this (recipient, tenant) pair. Mismatches → 403.
identify() returns a tenantId. Even if client code sends a forged tenantId in the request body, it is ignored — the server-resolved value always wins.Authorization
Some routes require explicit permission. Configure with authorize or return a permissions array from identify():
createHandler(notify, {
identify: async (request) => {
const session = await auth(request)
if (!session) return null
return {
recipientId: session.user.id,
tenantId: session.orgId,
permissions: session.role === "admin" ? ["admin"] : [],
}
},
// Or use the authorize hook for dynamic checks:
authorize: async (ctx, permission) => {
if (permission === "deliveries.list") {
return ctx.identity.permissions?.includes("admin") ?? false
}
return false
},
})Permission checks by route
Not all routes check authorize. Most are scoped by identity alone (only your own data). These routes pass a permission string to authorize():
| Route | Permission | Default if no authorize |
|---|---|---|
GET /deliveries | "deliveries.list" | Denied unless identity permissions include deliveries.list or admin |
identify(). You only need authorize for access to delivery records. To hide notification metadata from unauthenticated users, set protectNotifications: true; that route then requires a valid identity but does not call authorize().Delivery record redaction
The GET /deliveries handler strips sensitive fields (body, subject, to) before returning records. Server-side notify.deliveries.list() returns full records because it runs in trusted code.
Payload field redaction
Notification definitions can declare sensitive fields that get masked in logs and external surfaces:
notification({
id: "password_changed",
payload: { email: "string", ip: "string" },
channels: [inbox({ title: "Password changed from {{ip}}" })],
redact: ["email", "ip"],
})
// In hooks, timeline, and delivery logs:
// { email: "[REDACTED]", ip: "[REDACTED]" }Unsubscribe link security
Unsubscribe links live in emails that get forwarded, cached, and indexed. They must be unforgeable but work without a login session.
| Property | Why |
|---|---|
| HMAC-SHA256, timing-safe compare | Prevents brute-force token forgery and timing side-channels |
| Bound to (recipientId, notificationId, tenantId) | Can't reuse one user's link to unsubscribe another |
Bypasses identify() | Works from email clients without a session — the signature is the auth |
| No expiry | RFC 8058 requires links to remain valid indefinitely |
Rotate unsubscribe.secret to revoke | Invalidates all outstanding links if the key is compromised |
Webhook signatures
NotifyKit signs outgoing webhooks and verifies incoming provider webhooks. Both prevent forged requests from being processed.
Outgoing: signing your webhook deliveries
When you configure a webhook channel with a signing secret, every outgoing request includes an x-notifykit-signature header:
// In your notification definition:
channel.webhook({
url: "https://your-service.com/hooks/notify",
secret: process.env.WEBHOOK_SECRET, // enables signing
})
// The outgoing request includes:
// x-notifykit-signature: sha256=<hex-encoded HMAC-SHA256 of the body>Verifying on the receiving end
Your webhook receiver must verify the signature before processing the payload. Reject any request with an invalid or missing signature:
import { createHmac, timingSafeEqual } from "crypto"
function verifyWebhookSignature(
body: string,
signatureHeader: string | null,
secret: string
): boolean {
if (!signatureHeader) return false
const expected = "sha256=" + createHmac("sha256", secret)
.update(body)
.digest("hex")
// Timing-safe compare prevents timing side-channel attacks
if (expected.length !== signatureHeader.length) return false
return timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
)
}
// Example: Express/Hono/Next.js API route
export async function POST(request: Request) {
const body = await request.text()
const signature = request.headers.get("x-notifykit-signature")
if (!verifyWebhookSignature(body, signature, process.env.WEBHOOK_SECRET!)) {
return new Response("Invalid signature", { status: 401 })
}
const payload = JSON.parse(body)
// Safe to process — signature verified
await handleNotification(payload)
return new Response("OK", { status: 200 })
}| Verification step | Why | Without it |
|---|---|---|
| Check header exists | Missing header means unsigned — could be a forged request | Attacker can POST any payload to your endpoint |
| Compute expected HMAC | Hash the raw body with your shared secret | Can't distinguish real from forged requests |
| Timing-safe compare | Constant-time comparison prevents leaking valid characters | Attacker brute-forces signature byte-by-byte via timing |
| Use raw body (not parsed) | JSON.parse/stringify can reorder keys, breaking the hash | Signature fails on valid requests (false negative) |
JSON.parse(body) then JSON.stringify() to recreate it — JSON serialization doesn't guarantee key order. Read the body as text first, verify the signature, then parse.Incoming: verifying provider webhooks
Provider webhooks (delivery status updates, bounces) are verified by a function you pass to the handler. NotifyKit rejects requests where verification fails with a 401:
createRouteHandler({
notifykit: notify,
identify: getIdentity,
webhooks: {
resend: (headers, body) => {
// Verify using provider's signing mechanism
const signature = headers.get("svix-signature")
return verifyResendWebhook(body, signature, process.env.RESEND_WEBHOOK_SECRET!)
},
},
onWebhookEvent: async (provider, payload) => {
// Process verified webhook (delivery status, bounces, opens)
if (payload.type === "email.bounced") {
await deactivateRecipientEmail(payload.data.to)
}
},
})Client SDK
recipientId on any method. All operations rely on the server handler to resolve the user. It is impossible for client code to read or modify another user's data — even if the browser's network requests are tampered with.Rate limiting & CORS
createHandler(notify, {
identify: getIdentity,
requestRateLimit: {
max: 60, // per identity
windowMs: 60_000, // sliding window
},
cors: "https://app.example.com", // or string[]
})| Option | Protects against | Note |
|---|---|---|
requestRateLimit | Abusive clients hammering endpoints | Per-identity. Returns 429. Doesn't cover unauthenticated routes. |
cors | Cross-origin request forgery | Set to your app's origin. Accepts string or string[]. |
Security checklist
Prioritized by impact. Complete the "before launch" items before deploying to real users. The "harden later" items reduce attack surface but aren't blockers for launch.
| Item | Action | Risk if skipped |
|---|---|---|
NOTIFYKIT_SECRET | 32+ byte random hex, stored in secrets manager. Never in git. | Attackers forge unsubscribe links → mass opt-out of real users |
identify() → null | Return null (not a default user) for unauthenticated requests | Anyone can read any inbox and change any preferences without auth |
| Tenant scoping | Always return tenantId from identify() if multi-tenant | Cross-org data leaks — Org A sees Org B's notifications |
| CORS origin | Set to your app's domain. Never "*" in production | Malicious sites make authenticated requests using your users' cookies |
| Item | Action | What it prevents |
|---|---|---|
| Request rate limiting | Set requestRateLimit on the handler (e.g. 60/min) | Abusive scripts hammering your DB with rapid inbox polls |
| IP-based rate limiting | Add at your reverse proxy for /unsubscribe | Brute-force token enumeration on the unauthenticated route |
redact PII fields | Mark emails, IPs, names in notification definitions | PII exposure in logs, timeline, and hook payloads |
| Webhook signing | Set a secret on webhook channels; verify on the receiving end | Forged webhook deliveries triggering actions in downstream services |
protectNotifications | Set to true to require auth for GET /notifications | Unauthenticated enumeration of your notification IDs and structure |
Production-hardened handler
A complete handler config that applies every security recommendation from this page. Copy and adapt to your auth layer:
import { createRouteHandler } from "@notifykitjs/next"
import { notify } from "@/lib/notifykit"
import { auth } from "@/lib/auth"
export const { GET, POST, DELETE, OPTIONS, dynamic } = createRouteHandler({
notifykit: notify,
// Auth: resolve identity or reject
identify: async (request) => {
const session = await auth(request)
if (!session) return null // → 401
return {
recipientId: session.user.id,
tenantId: session.organizationId,
workspaceId: session.workspaceId,
permissions: session.user.role === "admin" ? ["admin"] : [],
}
},
// Admin routes: only admins can list all deliveries
authorize: async (ctx, permission) => {
return ctx.identity.permissions?.includes("admin") ?? false
},
// Unsubscribe: HMAC-signed links in emails
unsubscribeSecret: process.env.NOTIFYKIT_SECRET,
// Rate limiting: 60 requests per minute per identity
requestRateLimit: {
max: 60,
windowMs: 60_000,
},
// CORS: only your frontend origin
cors: process.env.NEXT_PUBLIC_APP_URL!,
// Notification metadata: require auth to list
protectNotifications: true,
})| Line | Protects against | Without it |
|---|---|---|
identify → null | Unauthenticated access to all user data | Anyone can read inboxes and change preferences |
tenantId in return | Cross-tenant data leaks | Org A users see Org B notifications |
authorize | Privilege escalation to admin routes | Regular users can list all delivery records |
requestRateLimit | Denial-of-service via rapid polling | A script can hammer your DB with unlimited inbox reads |
cors | Cross-site request forgery | Malicious sites can make requests using your users' cookies |
protectNotifications | Information disclosure of notification IDs | Unauthenticated users can enumerate your notification types |
/unsubscribe route. requestRateLimit only protects authenticated endpoints.Secret rotation
Every unsubscribe link ever sent is signed with your NOTIFYKIT_SECRET. If you rotate the secret in one step, every outstanding link in every email your users have ever received breaks instantly. Use the dual-secret pattern to rotate gracefully:
Deploy with both secrets. New links are signed with the new secret. Old links still verify against the old one.
Most email clients surface messages for 30–90 days. Wait at least that long before removing the old secret.
Once the overlap window passes, remove the old secret. Only the new secret remains.
import { createNotifyKit } from "@notifykitjs/core"
export const notify = createNotifyKit({
// ...notifications, database, providers
unsubscribe: {
// Primary secret — used for signing NEW links
secret: process.env.NOTIFYKIT_SECRET!,
// Previous secret(s) — verified on incoming unsubscribes
// but never used for signing new links
previousSecrets: process.env.NOTIFYKIT_SECRET_OLD
? [process.env.NOTIFYKIT_SECRET_OLD]
: [],
baseUrl: process.env.NEXT_PUBLIC_APP_URL + "/api/notifykit",
},
})| When to rotate | Urgency | Overlap window |
|---|---|---|
| Secret leaked in logs or git | Immediate — rotate today | Keep old secret for 90 days (links are already in the wild) |
| Employee with access left | Within a week | Keep old secret for 90 days |
| Compliance policy (quarterly rotation) | Scheduled | Keep old secret for 90 days, then remove on next rotation |
| Suspected active compromise | Immediate — rotate and remove old | Zero — accept that old links break. Attacker can't forge new ones. |
secret for signing, the receiving service must accept both old and new signatures during the transition. Coordinate the rotation with the team that owns the webhook receiver.Testing your security configuration
Security configuration that isn't tested in CI will eventually regress — someone changes the auth middleware, refactors the handler, or updates a dependency. These tests verify the security contract holds across deploys.
| What to test | Why it matters | Regression risk |
|---|---|---|
| Unauthenticated requests get 401 | Ensures identify() rejects missing sessions | Auth middleware change, cookie format change, missing null check |
| Cross-tenant access gets 403 or empty | Proves data isolation is enforced at the query layer | Scoping WHERE clause removed during refactor |
| Invalid unsubscribe token gets 401 | Confirms HMAC verification rejects forged links | Secret env var missing in new environment |
| Rate limiting returns 429 | Verifies abusive clients are throttled | Rate limit config removed or misconfigured after handler refactor |
Pattern: security boundary tests
import { describe, it, expect } from "vitest"
import { createNotifyKit, memoryAdapter, fakeEmailProvider, createHandler } from "@notifykitjs/core"
import { commentMentioned } from "./notifications"
function setup() {
const notify = createNotifyKit({
notifications: [commentMentioned] as const,
database: memoryAdapter(),
providers: { email: fakeEmailProvider() },
})
return notify
}
describe("security boundaries", () => {
it("rejects unauthenticated requests with 401", async () => {
const notify = setup()
const handler = createHandler(notify, {
identify: async () => null, // no session
})
const res = await handler(new Request("http://localhost/api/notifykit/inbox"))
expect(res.status).toBe(401)
const body = await res.json()
expect(body.code).toBe("UNAUTHENTICATED")
})
it("rejects cross-tenant inbox access", async () => {
const notify = setup()
await notify.upsertRecipient({ id: "alice", tenantId: "org_a", email: "a@test.com" })
await notify.send({
recipientId: "alice",
tenantId: "org_a",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postUrl: "/p/1" },
})
// Handler scoped to org_b — should NOT see org_a's items
const handler = createHandler(notify, {
identify: async () => ({ recipientId: "alice", tenantId: "org_b" }),
})
const res = await handler(new Request("http://localhost/api/notifykit/inbox"))
const { data: items } = await res.json()
expect(items).toHaveLength(0) // ✓ isolated
})
it("prevents preference writes to another tenant", async () => {
const notify = setup()
await notify.upsertRecipient({ id: "alice", tenantId: "org_a", email: "a@test.com" })
// Handler scoped to org_b tries to write preferences for org_a
const handler = createHandler(notify, {
identify: async () => ({ recipientId: "alice", tenantId: "org_b" }),
})
const res = await handler(new Request("http://localhost/api/notifykit/preferences", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
notificationId: "comment_mentioned",
channels: { email: false },
}),
}))
// Preference is written to org_b scope (the handler's scope), NOT org_a
// Verify org_a's preferences are untouched
const prefs = await notify.preferences.list("alice", { tenantId: "org_a" })
expect(prefs).toHaveLength(0)
})
it("rejects forged unsubscribe tokens", async () => {
const notify = setup()
const handler = createHandler(notify, {
identify: async () => ({ recipientId: "alice" }),
unsubscribeSecret: "real-secret-32-bytes-long-here!!",
})
// Forged token
const res = await handler(new Request(
"http://localhost/api/notifykit/unsubscribe?token=forged_token_value"
))
expect(res.status).toBe(400)
})
it("enforces rate limiting with 429", async () => {
const notify = setup()
await notify.upsertRecipient({ id: "alice", email: "a@test.com" })
const handler = createHandler(notify, {
identify: async () => ({ recipientId: "alice" }),
requestRateLimit: { max: 3, windowMs: 60_000 },
})
// Fire requests up to the limit
for (let i = 0; i < 3; i++) {
const res = await handler(new Request("http://localhost/api/notifykit/inbox"))
expect(res.status).toBe(200)
}
// Next request should be rate-limited
const blocked = await handler(new Request("http://localhost/api/notifykit/inbox"))
expect(blocked.status).toBe(429)
expect(blocked.headers.get("Retry-After")).toBeDefined()
})
})Testing authorization levels
If you use authorize() for admin routes, verify that non-admin users get 403 while admins pass through:
describe("authorization", () => {
function handlerWithRole(role: "user" | "admin") {
return createHandler(notify, {
identify: async () => ({
recipientId: "alice",
permissions: role === "admin" ? ["admin"] : [],
}),
authorize: async (ctx, permission) => {
if (permission === "deliveries.list") {
return ctx.identity.permissions?.includes("admin") ?? false
}
return true
},
})
}
it("admin can list deliveries", async () => {
const handler = handlerWithRole("admin")
const res = await handler(new Request("http://localhost/api/notifykit/deliveries"))
expect(res.status).toBe(200)
})
it("regular user cannot list deliveries", async () => {
const handler = handlerWithRole("user")
const res = await handler(new Request("http://localhost/api/notifykit/deliveries"))
expect(res.status).toBe(403)
})
it("regular user can still access their own inbox", async () => {
const handler = handlerWithRole("user")
const res = await handler(new Request("http://localhost/api/notifykit/inbox"))
expect(res.status).toBe(200)
})
})| Test category | Catches | Run frequency |
|---|---|---|
| Auth boundary (401) | identify() returning a user when it shouldn't | Every CI run — fast, no external deps |
| Tenant isolation (403/empty) | Missing WHERE clauses, unscoped queries | Every CI run — most critical for multi-tenant apps |
| Token verification | Broken HMAC, missing secret in env, timing vulnerabilities | Every CI run — tests the crypto path |
| Rate limiting | Rate limit config removed or max set too high | Every CI run — verifies the sliding window logic |
| Authorization (admin routes) | Permission checks bypassed, authorize() not wired up | Every CI run — prevents privilege escalation |
identify() passes all feature tests while being wide open.