7 Powerful Secure Web Push Patterns for Chrome 143 (Rate Limits, Tokens, UX)
Chrome’s latest stable updates (including Chrome 143 builds) are reinforcing what many engineering teams already learned the hard way: web push notifications are a trust channel. If your Secure Web Push implementation looks “spammy” (high volume + low engagement), modern browsers increasingly degrade or limit delivery. That’s good for users—and it’s a forcing function for teams to ship secure web push that’s intentional, abuse-resistant, and observable.
This guide is a code-heavy, production-minded playbook for Secure Web Push in 2026—covering:
- A realistic threat model (permission phishing → service worker abuse)
- Secure UX patterns (intent-based prompts + cooldowns)
- Backend controls (quotas, abuse scoring, token rotation, replay protection)
- Service worker hardening (scope control, payload validation, minimal caching)
- CI gates (automated checks that block unsafe push endpoints)

If you’re tightening CI/CD security this year, don’t miss “7 Proven Supply-Chain CI Hardening Wins (2026)”—a practical guide to Supply-Chain CI Hardening with copy/paste workflows, SBOM generation, SLSA provenance, and policy gates that fail unsafe builds closed.
Why Secure Web Push matters more now (Chrome 143 + rate limits)
Chrome has begun rolling out Push API message rate limits for sites that send many push messages without meaningful engagement. Practically, if a site behaves like a “notification cannon,” delivery can be throttled (e.g., excess requests returning HTTP 429). That means “blast” strategies don’t just annoy users—they can directly reduce reliability.
So the goal isn’t only security. It’s deliverability + trust + abuse resistance.
1) Threat model: how web push gets abused
A Secure Web Push design must defend against human abuse and technical abuse:
Permission phishing + spam prompts
Attackers (or shady growth tactics) push users into clicking “Allow” via:
- Misleading overlays (“Click Allow to verify you’re human”)
- Dark patterns (blocking content until permission is granted)
- Repeated prompts until the user gives up
Token theft + subscription hijack
If attackers steal your session/token, they can:
- Register their own push subscription under a victim account
- Trigger push spam to victims
- Use push as a social engineering channel (“Urgent: reset password”)
Service worker abuse
Service workers can be a persistence layer:
- Over-broad scope (controls more pages than intended)
- Weak payload validation (push payload becomes an injection vector)
- Over-caching sensitive content or API responses
Push endpoint abuse
Your /push/send (or similar) endpoint becomes a DoS and abuse target:
- Unauthenticated sending
- No rate limits
- No per-user quotas
- No replay protection
2) Secure UX pattern: intent-based prompts + cooldowns (no dark patterns)
A Secure Web Push UX rule that works: don’t prompt on page load. Prompt only after the user takes a relevant action (“intent signal”), and enforce cooldowns so your site never looks abusive.
Intent-based enable button (recommended)
<button id="enablePush" type="button">
Enable notifications for updates
</button>
<p id="pushStatus" aria-live="polite"></p>// ui/push-consent.js
const COOLDOWN_HOURS = 72;
const key = "pushPromptCooldownUntil";
function cooldownActive() {
const until = Number(localStorage.getItem(key) || 0);
return Date.now() < until;
}
function startCooldown() {
const until = Date.now() + COOLDOWN_HOURS * 60 * 60 * 1000;
localStorage.setItem(key, String(until));
}
async function canPrompt() {
if (cooldownActive()) return false;
if (!("Notification" in window) || !("serviceWorker" in navigator)) return false;
// Progressive request: only prompt if permission is still default
if (Notification.permission !== "default") return false;
return true;
}
document.getElementById("enablePush").addEventListener("click", async () => {
const status = document.getElementById("pushStatus");
if (!(await canPrompt())) {
status.textContent = "Notifications are already set, or prompt is cooling down.";
return;
}
// Tell the user what they get BEFORE the browser prompt
status.textContent = "We’ll only notify you about important account/security events.";
// If they bail, still cooldown (avoid repeated nagging)
startCooldown();
const permission = await Notification.requestPermission();
if (permission !== "granted") {
status.textContent = "No problem—notifications remain off.";
return;
}
status.textContent = "Enabled. Finishing setup…";
// Continue: register SW + subscribe
});Progressive permission requests (pattern)
Instead of “Enable notifications” globally, ask per category:
- Security alerts (high trust, low volume)
- Order updates (medium)
- Marketing/news (optional, and should be opt-in later)
This single change improves Secure Web Push engagement and reduces “spammy” signals.
3) Real implementation: subscribe safely (frontend)
Service worker registration with tight scope
Keep the service worker scoped to a dedicated path like /app/ or /push/.
// ui/push-register.js
async function registerSW() {
// Put your worker at /push/sw.js and restrict to /push/
const reg = await navigator.serviceWorker.register("/push/sw.js", { scope: "/push/" });
await navigator.serviceWorker.ready;
return reg;
}
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
async function subscribePush(reg, vapidPublicKey) {
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
return sub.toJSON();
}Send subscription to backend (authenticated + CSRF)
// ui/push-subscribe-api.js
async function saveSubscription(subscriptionJson) {
const res = await fetch("/api/push/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF": window.__CSRF_TOKEN__, // example
},
credentials: "include",
body: JSON.stringify({ subscription: subscriptionJson }),
});
if (!res.ok) throw new Error(`Subscribe failed: ${res.status}`);
return res.json();
}4) Backend controls: quotas + abuse scoring + token rotation + replay protection
Below is a pragmatic Node.js/TypeScript example that enforces Secure Web Push controls without overengineering.
Data model (store subscription + key id + status)
-- db/push_subscriptions.sql
CREATE TABLE push_subscriptions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
kid TEXT NOT NULL, -- key id for rotation
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_push_user ON push_subscriptions(user_id);Per-user quotas with Redis (simple + effective)
// api/limits.ts
import type { Request, Response, NextFunction } from "express";
export function perUserQuota(redis: any, opts: { keyPrefix: string; maxPerMin: number }) {
return async (req: Request, res: Response, next: NextFunction) => {
const userId = req.user?.id; // assumes auth middleware
if (!userId) return res.status(401).json({ error: "auth_required" });
const key = `${opts.keyPrefix}:${userId}:${new Date().toISOString().slice(0, 16)}`; // per-minute bucket
const n = await redis.incr(key);
if (n === 1) await redis.expire(key, 90);
if (n > opts.maxPerMin) {
return res.status(429).json({ error: "rate_limited" });
}
next();
};
}Replay protection with idempotency keys
Require an idempotency key on “send push” operations to block retries/abuse.
// api/idempotency.ts
import type { Request, Response, NextFunction } from "express";
export function requireIdempotency(redis: any, ttlSeconds = 300) {
return async (req: Request, res: Response, next: NextFunction) => {
const key = req.header("Idempotency-Key");
if (!key || key.length < 16) return res.status(400).json({ error: "missing_idempotency_key" });
const redisKey = `idem:${req.user.id}:${key}`;
const ok = await redis.set(redisKey, "1", "NX", "EX", ttlSeconds);
if (!ok) return res.status(409).json({ error: "replay_detected" });
next();
};
}Abuse scoring (keep it explainable)
Score “push privilege” using user engagement + complaint signals:
- Push click-through rate
- Opt-out rate
- “Mute” or “block” events
- Low engagement + high volume
// abuse/pushScore.ts
export function computePushAbuseScore(input: {
sent7d: number;
clicks7d: number;
optOuts30d: number;
complaints30d: number;
foregroundMinutes7d: number;
}) {
const ctr = input.sent7d ? input.clicks7d / input.sent7d : 0;
const perMinute = input.foregroundMinutes7d ? input.sent7d / input.foregroundMinutes7d : input.sent7d;
let score = 0;
if (perMinute > 0.5) score += 30; // too chatty per engagement
if (ctr < 0.01 && input.sent7d > 50) score += 25; // low value
if (input.optOuts30d > 5) score += 20;
if (input.complaints30d > 0) score += 40;
return Math.min(100, score);
}
export function pushAllowed(score: number) {
// deny-by-default above threshold
return score < 60;
}Token rotation pattern (KID + re-enroll)
For Secure Web Push, rotate keys and force re-subscription when needed (especially after incident response).
// push/keys.ts
export function currentKid() {
// example: rotate monthly or on demand
return process.env.PUSH_KID || "2026-01";
}
export function needsReEnroll(subscriptionKid: string) {
return subscriptionKid !== currentKid();
}When you detect a key rotation or suspected compromise:
- mark subscriptions with old
kidasenabled=false - prompt users (in-product) to re-enable notifications intentionally
This is secure web push token rotation without relying on “hope.”
5) Secure send endpoint (auth + limits + safe payload)
Express route: send push (locked down)
// api/pushRoutes.ts
import express from "express";
import { perUserQuota } from "./limits";
import { requireIdempotency } from "./idempotency";
export function pushRouter({ redis, webpush, db }: any) {
const r = express.Router();
// Only authenticated users can manage push
r.post("/subscribe", requireAuth, perUserQuota(redis, { keyPrefix: "push_sub", maxPerMin: 5 }), async (req, res) => {
const { subscription } = req.body || {};
if (!subscription?.endpoint || !subscription?.keys?.p256dh || !subscription?.keys?.auth) {
return res.status(400).json({ error: "invalid_subscription" });
}
await db.upsertSubscription({
userId: req.user.id,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
kid: currentKid(),
});
res.json({ ok: true });
});
r.post(
"/send",
requireAuth,
requireIdempotency(redis),
perUserQuota(redis, { keyPrefix: "push_send", maxPerMin: 10 }),
async (req, res) => {
const { title, body, url } = req.body || {};
// keep payload small + predictable
if (!title || typeof title !== "string" || title.length > 80) return res.status(400).json({ error: "bad_title" });
if (!body || typeof body !== "string" || body.length > 140) return res.status(400).json({ error: "bad_body" });
if (!url || typeof url !== "string" || !url.startsWith("/")) return res.status(400).json({ error: "bad_url" });
// Optional: enforce abuse scoring before sending
const score = await db.getPushScore(req.user.id);
if (!pushAllowed(score)) return res.status(403).json({ error: "push_blocked_by_policy" });
const subs = await db.getEnabledSubscriptions(req.user.id);
const payload = JSON.stringify({ t: title, b: body, u: url, v: 1 });
const results = [];
for (const sub of subs) {
// rotate keys by disabling old kid
if (needsReEnroll(sub.kid)) continue;
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth },
},
payload,
{ TTL: 60 } // short TTL = less abuse window
);
results.push({ endpoint: sub.endpoint, ok: true });
} catch (e: any) {
// If gone/invalid, disable it
await db.disableByEndpoint(sub.endpoint);
results.push({ endpoint: sub.endpoint, ok: false });
}
}
res.json({ ok: true, results });
}
);
return r;
}Secure Web Push takeaway: Your “send” endpoint must be treated like a payment endpoint: authenticated, rate-limited, replay-protected, and monitored.
6) Service worker hardening: strict scope + payload validation + minimal caching
Restrict scope at the server (important)
Configure your server to limit where the service worker can control:
Nginx example
location = /push/sw.js {
add_header Content-Type application/javascript;
add_header Service-Worker-Allowed "/push/";
add_header Cache-Control "no-store";
}Validate push payload in the service worker
Never trust payloads. Treat them like untrusted input.
// /push/sw.js
self.addEventListener("push", (event) => {
let data = null;
try {
data = event.data ? event.data.json() : null;
} catch (e) {
// Drop invalid payloads
return;
}
// Minimal schema validation
if (!data || data.v !== 1 || typeof data.t !== "string" || typeof data.b !== "string" || typeof data.u !== "string") {
return;
}
if (data.t.length > 80 || data.b.length > 140) return;
if (!data.u.startsWith("/")) return;
const title = data.t;
const options = {
body: data.b,
data: { url: data.u },
tag: "secure-web-push", // dedupe
renotify: false,
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification?.data?.url || "/";
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: false }).then((wins) => {
for (const w of wins) {
if ("focus" in w) {
w.navigate(url);
return w.focus();
}
}
return clients.openWindow(url);
})
);
});Minimal caching rule
If you use caching in service workers, keep it narrow:
- Cache only static assets that are safe
- Never cache authenticated HTML or API responses
- Never cache tokens, session responses, or “me” endpoints
7) CI gate: block unsafe push changes before they ship
Secure Web Push needs “guardrails you can’t forget.” Add a CI check that fails PRs if push endpoints lose auth/rate limits.
Example: Jest test verifying middleware presence
// test/pushRoutes.security.test.ts
import request from "supertest";
import { app } from "../app";
describe("Secure Web Push gate", () => {
it("blocks unauthenticated push send", async () => {
const res = await request(app).post("/api/push/send").send({ title: "t", body: "b", url: "/x" });
expect([401, 403]).toContain(res.status);
});
it("requires Idempotency-Key", async () => {
const agent = await loginAsTestUser(); // your helper
const res = await agent.post("/api/push/send").send({ title: "t", body: "b", url: "/x" });
expect(res.status).toBe(400);
expect(res.body.error).toBe("missing_idempotency_key");
});
});GitHub Actions workflow snippet
# .github/workflows/secure-web-push-gate.yml
name: secure-web-push-gate
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run lint
- run: npm testThis is the simplest version of a Secure Web Push CI gate—and it catches the most expensive mistakes early.
Free Website Vulnerability Scanner tool page

Sample report by our tool to check Website Vulnerability

When you need help (assessment + remediation)
If you want an expert review of your push endpoints, service worker scope, auth controls, and abuse defenses:
- Risk Assessment Services: https://www.pentesttesting.com/risk-assessment-services/
- Remediation Services: https://www.pentesttesting.com/remediation-services/
These are especially useful when push is part of account security, payments, or high-trust user workflows.
Recent reads from Cyber Rely
If you’re building Secure Web Push as part of a broader engineering security program, these are relevant follow-ups:
- OWASP playbook (2026-ready controls): https://www.cybersrely.com/owasp-top-10-for-llm-apps-2025-playbook/
- CI security gate patterns: https://www.cybersrely.com/asvs-5-0-gate-to-ci-cd/
- Fix leaked tokens (incident-ready steps): https://www.cybersrely.com/fix-a-leaked-github-token/
- Non-human identity controls (keys, service accounts, CI tokens): https://www.cybersrely.com/non-human-identity-security-controls/
- CI gating with known exploited vulns: https://www.cybersrely.com/gate-ci-with-cisa-kev-json/
- Access control fixes in Node.js: https://www.cybersrely.com/broken-access-control-in-node-js/
Secure Web Push checklist (copy/paste)
- UX: intent-based prompt, cooldown, progressive permission categories
- Auth: subscribe/send endpoints require auth + CSRF protection
- Abuse controls: per-user quotas + engagement-aware policy
- Replay protection: idempotency keys on send operations
- Rotation: key id (KID), re-enroll flow, incident kill switch
- Service worker: strict scope, payload validation, minimal caching
- CI gate: tests block unauthenticated/unsafe push changes
- Evidence: store reports + logs (useful for audits and incident response)
🔐 Frequently Asked Questions (FAQs)
Find answers to commonly asked questions about Secure Web Push Patterns for Chrome 143.