better-push
Operate in production

Security

The boundaries better-push enforces, and the ones your application owns.

better-push runs inside your application, against your database, using your session. It defends the surfaces it owns—its endpoints, queries, stored notification records, and provider hand-off—but it cannot replace your authentication, database security, proxy policy, or credential management.

What better-push does

Every public router endpoint resolves your session and scopes database reads and writes to its userId. The only exception is POST {basePath}/_internal/run-pending: a scheduler has no user session, so the route exists only when runPendingSecret is configured and compares its bearer token in constant time. Per-user rate limits protect reads, writes, and open SSE streams without trusting proxy-provided IP headers.

State-changing requests (POST, PUT, PATCH, and DELETE) must be same-origin. Requests with no Origin remain available to native clients, cron, and server-to-server callers. Sec-Fetch-Site: cross-site is refused, even if an allowlist entry would otherwise match. Add exact origins when a browser is served elsewhere:

const push = betterPush({
  // ...
  trustedOrigins: ["https://app.example.com"],
});

There are no wildcard origins. Use the function form for a dynamic tenant allowlist. Behind a proxy, the default same-origin comparison uses the origin of request.url; pin the proxy's Host header or configure the list explicitly. trustedOrigins: false disables this defense and is appropriate only when an upstream layer enforces an equivalent policy.

Requests are bounded before JSON parsing. Defaults are 64 KiB per body, 4 KiB per device token, 200 preferences per update, and 8 KiB for serialized notification content. Configure larger values through limits when the application genuinely needs them.

Feed cursors are opaque rather than signed; safety comes from every cursor query still filtering by user_id. SQL is composed from static chunks that cannot contain $, keeping parameterization structural. Private JSON responses are private, no-store and vary on cookies and authorization. Device tokens never appear in client JSON or studio queries.

The studio omits title, body, and data unless your permission callback grants content. A reveal is a separate, audited action. Realtime carries only a change signal; logs, metrics, diagnostics, errors, and audit metadata do not carry notification content.

What better-push does not do

better-push does not authenticate a user; your session callback does. It does not provide CORS, because its browser endpoints are designed to be mounted in the app they serve. It does not perform IP rate limiting because that requires an explicit proxy-trust policy. Put unauthenticated edge limits on your proxy or CDN. It does not encrypt notification payloads at rest; use your database and storage controls. It cannot protect a provider after its credentials are compromised.

What your application must do

  • Keep VAPID private keys, APNs p8 keys, Firebase service accounts, Expo access tokens, database credentials, and runPendingSecret in a secret manager.
  • Use a random runPendingSecret of at least 32 characters, rotate it like an API credential, and also limit the endpoint at the platform edge.
  • Rotate provider credentials in the provider console, deploy the replacement, verify delivery, and revoke the old credential.
  • Choose a cookie SameSite policy intentionally. If cross-site cookies are necessary, keep the origin gate enabled and enumerate the calling apps.
  • Treat the studio permission callback as the admin authorization boundary. allowWrites enables actions; it does not decide who is an administrator.
  • Do not put secrets in notification data. It is stored in your database and delivered to a device in clear application data.

Reporting

Report vulnerabilities privately using the repository's SECURITY.md. Do not open a public issue containing exploit details or real user data.

Configuration reference

Prop

Type

On this page