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.

app/api/notifykit/[...notifykit]/route.ts
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.

SurfaceIsolationCross-tenant attempt
Inbox reads & mutationsScoped by tenantId403 or empty set
Preference reads & writesScoped by tenantId403 or empty set
Delivery logsScoped by tenantIdFiltered to empty
Realtime SSE eventsScoped by tenantIdNever receives foreign events
Unsubscribe linksBound to (recipient, tenant)HMAC fails → 401

How isolation is enforced

1
identify() returns scope

Your function extracts recipientId + tenantId from the request's session.

2
Handler injects WHERE clauses

Every DB query appends WHERE tenant_id = :tenantId AND recipient_id = :recipientId. Not optional — you can't skip this.

3
Mutations verify ownership

Mark-read, archive, delete check the item belongs to this (recipient, tenant) pair. Mismatches → 403.

Isolation is structural, not advisory. There is no API parameter or configuration that disables tenant scoping once 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():

RoutePermissionDefault if no authorize
GET /deliveries"deliveries.list"Denied unless identity permissions include deliveries.list or admin
Most routes don't need authorize. Inbox, preferences, and realtime routes are automatically scoped to the authenticated user by 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.

PropertyWhy
HMAC-SHA256, timing-safe comparePrevents 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 expiryRFC 8058 requires links to remain valid indefinitely
Rotate unsubscribe.secret to revokeInvalidates 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:

lib/verify-webhook.ts
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 stepWhyWithout it
Check header existsMissing header means unsigned — could be a forged requestAttacker can POST any payload to your endpoint
Compute expected HMACHash the raw body with your shared secretCan't distinguish real from forged requests
Timing-safe compareConstant-time comparison prevents leaking valid charactersAttacker brute-forces signature byte-by-byte via timing
Use raw body (not parsed)JSON.parse/stringify can reorder keys, breaking the hashSignature fails on valid requests (false negative)
Always verify against the raw body string. Don't call 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

Key invariant. The React client SDK does not accept 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[]
})
OptionProtects againstNote
requestRateLimitAbusive clients hammering endpointsPer-identity. Returns 429. Doesn't cover unauthenticated routes.
corsCross-origin request forgerySet 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.

Before launch — blocking:
ItemActionRisk if skipped
NOTIFYKIT_SECRET32+ byte random hex, stored in secrets manager. Never in git.Attackers forge unsubscribe links → mass opt-out of real users
identify() → nullReturn null (not a default user) for unauthenticated requestsAnyone can read any inbox and change any preferences without auth
Tenant scopingAlways return tenantId from identify() if multi-tenantCross-org data leaks — Org A sees Org B's notifications
CORS originSet to your app's domain. Never "*" in productionMalicious sites make authenticated requests using your users' cookies
Harden after launch — defense in depth:
ItemActionWhat it prevents
Request rate limitingSet requestRateLimit on the handler (e.g. 60/min)Abusive scripts hammering your DB with rapid inbox polls
IP-based rate limitingAdd at your reverse proxy for /unsubscribeBrute-force token enumeration on the unauthenticated route
redact PII fieldsMark emails, IPs, names in notification definitionsPII exposure in logs, timeline, and hook payloads
Webhook signingSet a secret on webhook channels; verify on the receiving endForged webhook deliveries triggering actions in downstream services
protectNotificationsSet to true to require auth for GET /notificationsUnauthenticated 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:

app/api/notifykit/[...notifykit]/route.ts
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,
})
LineProtects againstWithout it
identify → nullUnauthenticated access to all user dataAnyone can read inboxes and change preferences
tenantId in returnCross-tenant data leaksOrg A users see Org B notifications
authorizePrivilege escalation to admin routesRegular users can list all delivery records
requestRateLimitDenial-of-service via rapid pollingA script can hammer your DB with unlimited inbox reads
corsCross-site request forgeryMalicious sites can make requests using your users' cookies
protectNotificationsInformation disclosure of notification IDsUnauthenticated users can enumerate your notification types
This handler alone isn't enough. Add IP-based rate limiting at your reverse proxy (Vercel, Cloudflare, nginx) for the unauthenticated /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:

1
Add new secret alongside old

Deploy with both secrets. New links are signed with the new secret. Old links still verify against the old one.

2
Wait for old links to expire from inboxes

Most email clients surface messages for 30–90 days. Wait at least that long before removing the old secret.

3
Remove old secret

Once the overlap window passes, remove the old secret. Only the new secret remains.

lib/notifykit.ts
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 rotateUrgencyOverlap window
Secret leaked in logs or gitImmediate — rotate todayKeep old secret for 90 days (links are already in the wild)
Employee with access leftWithin a weekKeep old secret for 90 days
Compliance policy (quarterly rotation)ScheduledKeep old secret for 90 days, then remove on next rotation
Suspected active compromiseImmediate — rotate and remove oldZero — accept that old links break. Attacker can't forge new ones.
Compromised secret = attacker can forge unsubscribe links. They can't read inbox data or send notifications (those require server access), but they can unsubscribe any user from any notification by crafting a valid token. In an active compromise, remove the old secret immediately — broken unsubscribe links are less harmful than an attacker silently disabling notifications for your users.
Webhook secrets rotate the same way. If your webhook channel uses a 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 testWhy it mattersRegression risk
Unauthenticated requests get 401Ensures identify() rejects missing sessionsAuth middleware change, cookie format change, missing null check
Cross-tenant access gets 403 or emptyProves data isolation is enforced at the query layerScoping WHERE clause removed during refactor
Invalid unsubscribe token gets 401Confirms HMAC verification rejects forged linksSecret env var missing in new environment
Rate limiting returns 429Verifies abusive clients are throttledRate limit config removed or misconfigured after handler refactor

Pattern: security boundary tests

tests/security-boundaries.test.ts
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:

tests/authorization.test.ts
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 categoryCatchesRun frequency
Auth boundary (401)identify() returning a user when it shouldn'tEvery CI run — fast, no external deps
Tenant isolation (403/empty)Missing WHERE clauses, unscoped queriesEvery CI run — most critical for multi-tenant apps
Token verificationBroken HMAC, missing secret in env, timing vulnerabilitiesEvery CI run — tests the crypto path
Rate limitingRate limit config removed or max set too highEvery CI run — verifies the sliding window logic
Authorization (admin routes)Permission checks bypassed, authorize() not wired upEvery CI run — prevents privilege escalation
Run these on every PR. Security tests are fast (in-memory adapter, no network) and catch regressions that functional tests miss — a refactored handler that works correctly but forgot to check identify() passes all feature tests while being wide open.
Test the negative case, not just the positive. A test that verifies "admin can list deliveries" passes even if everyone can list deliveries. Always pair it with "non-admincannot list deliveries" — the denial test is what proves the guard exists.