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
runPendingSecretin a secret manager. - Use a random
runPendingSecretof 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
SameSitepolicy 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.
allowWritesenables 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