OneCeylon logo
OneCeylon
Tech & Careers
NOTEBOOK Engineering · Research · Field notes

The notebook.

Where the engineers and researchers at OneCeylon write about what they are making. No hot takes, no roadmaps — just the work, and what we learned doing it.

ENGINEERING 24 August 2026 · 9 min read · By OneCeylon Engineering

Nobody ever got a push notification from us.

Our Web Push had a settings toggle, a green “On for this browser”, and a zero percent delivery rate. Here are the two bugs — and the reason neither one ever surfaced.

I was auditing our notification system recently, reading through lib/push-notifications.ts, when something stopped me. We pull the subscription keys out of the database:

const result = await query(
  `SELECT id, endpoint, p256dh_key AS p256dh, auth_key AS auth
     FROM push_subscriptions WHERE user_id = ?`,
  [userId]
);

And then we never use them.

Not “use them incorrectly.” Never reference them again. The next thing the function does is stringify the payload and POST it:

const body = JSON.stringify(payload);
// ...
const res = await fetch(sub.endpoint, {
  method: "POST",
  headers,  // Authorization + Content-Type: application/octet-stream
  body,
});

A plain JSON string, sent with a content type claiming it's binary.

If you've worked with Web Push you already know what's wrong. If you haven't: the payload of a Web Push message is required to be encrypted end to end, with a key derived from exactly those two values we were throwing away. The push service — Google's, Mozilla's, Apple's — can't read the message and isn't supposed to. It's a dumb pipe holding an opaque blob for a browser that might be offline. Hand it unencrypted JSON with no Content-Encoding header and it rejects the request.

So our push notifications had never worked. Not degraded. Not flaky. Zero delivered, since the day the feature shipped.

The part that actually bothers me

The bug is embarrassing, but it's a bug. The interesting question is how it survived.

We had a settings page with a toggle. Turning it on fired a real browser permission prompt, got a real PushSubscription, and stored real keys in a real table. The switch then read “On for this browser,” which was completely true. It just didn't mean anything.

And when we sent, we handled the response like this:

if (res.status === 410 || res.status === 404) {
  expiredEndpoints.push(sub.endpoint);
}
// ...
} catch {
  // Network error — ignore
}

Two named outcomes. Everything else falls out the bottom.

Read that carefully. There are exactly two outcomes this code can distinguish: “the subscription is gone,” and “something threw.” Everything else — 200, 201, 400, 401, 403, 429, 500 — falls through the bottom of the function identically. And the function returned void.

We were getting a 400 on every single send. The code had no branch capable of telling a 400 from a 201.

That's the real lesson, and it isn't “test your push notifications.” It's that we wrote error handling that assumed success was the default case. The only paths we named were the two ways we expected things to go wrong. Success was the unhandled remainder.

A function that returns nothing and only inspects the failures it anticipated cannot report a 100% failure rate. It has no vocabulary for the difference.
Bug two of two

The one that would have been fatal on its own

While rewriting the encryption I found the second one. Web Push authenticates the sender using VAPID: a JWT signed ES256, carrying the push service's origin as its audience. We were signing it like this:

const sig = crypto.createSign("SHA256")
  .update(signingInput)
  .sign(privateKeyPem, "base64url");

Which is wrong in a way that's genuinely easy to miss, because it produces a perfectly valid ECDSA signature. Node's default output for ECDSA is DER — an ASN.1 SEQUENCE of two INTEGERs, variable length, usually 70 to 72 bytes. ES256 as specified by JWS requires the raw concatenation of R and S: exactly 64 bytes, no wrapper.

The fix is one option:

.sign({ key, dsaEncoding: "ieee-p1363" })

So even if we had been encrypting correctly, every request would have been rejected for a malformed token. Two independent, individually fatal bugs in one feature — and our instrumentation could distinguish neither of them from working.

There was also a createSign object that got constructed, updated, and then never used. Harmless, but it's the kind of thing that tells you nobody had read this function closely in a long while.

delivered
0%
since launch
fatal bugs
2
each sufficient alone
outcomes named
2
neither was success
signature
72 B
DER, needed 64

Writing it properly

The obvious move is npm i web-push and delete our code. We couldn't: our build environment can't currently reach the npm registry — a CA bundle problem that's its own separate post — so adding a dependency wasn't on the table.

So: hand-rolled, from RFC 8188 (the aes128gcm content coding), RFC 8291 (message encryption for Web Push), and RFC 8292 (VAPID). Which is a sentence that should make you nervous. It made me nervous.

The algorithm itself is short. Generate an ephemeral P-256 keypair. ECDH against the subscriber's public key for a shared secret. Run that through HKDF with the subscription's auth secret and an info string binding both public keys — which is what stops a message being replayed against a different subscription. Then a random 16-byte salt, HKDF again for a content encryption key and a nonce, and AES-128-GCM the plaintext.

const ecdh = crypto.createECDH("prime256v1");
ecdh.generateKeys();
const sharedSecret = ecdh.computeSecret(uaPublic);

const keyInfo = Buffer.concat([
  Buffer.from("WebPush: info\0"), uaPublic, ecdh.getPublicKey(),
]);
const ikm = hkdf(authSecret, sharedSecret, keyInfo, 32);

const salt  = crypto.randomBytes(16);
const cek   = hkdf(salt, ikm, Buffer.from("Content-Encoding: aes128gcm\0"), 16);
const nonce = hkdf(salt, ikm, Buffer.from("Content-Encoding: nonce\0"), 12);

The plaintext gets a trailing 0x02 byte before encryption — the record delimiter marking this as the final record. Leave it off and everything still looks fine: the push service accepts your request, returns 201, and the browser silently discards the message when decryption yields no valid delimiter. That's the nastiest failure mode in the whole spec, because it's the one that looks like success from the server's side.

Then the body is a header, followed by the ciphertext:

salt rec size idlen ephemeral key ciphertext + GCM tag 16 bytes 4 bytes 1 byte 65 bytes rest HEADER — 21 BYTES + KEY The aes128gcm wire format not to scale
Figure 1 — Each piece has to be exactly right, and there is no error message if it isn't.

Testing crypto you can't send

Here's the part I'm actually pleased with.

You cannot test Web Push against a push service in CI. There's no test endpoint, the real ones need a genuine subscription from a genuine browser, and — as we've just established — a 201 doesn't tell you the browser could read it.

But you don't need one. The push service is a pipe. The thing that has to be able to decrypt your payload is a browser holding a P-256 private key, and you can simply be that browser:

const ua = crypto.createECDH("prime256v1");
ua.generateKeys();
const authSecret = crypto.randomBytes(16);

const body = encryptPushPayload(message, {
  endpoint: "https://example.invalid/x",
  p256dh: ua.getPublicKey().toString("base64url"),
  auth: authSecret.toString("base64url"),
});

// ...then parse the header and decrypt exactly as a browser would.

Then assert you get your plaintext back, that the delimiter is 0x02, and that two consecutive sends don't reuse the ephemeral key. Same trick for VAPID: sign a token, then verify it with crypto.verify(…, { dsaEncoding: "ieee-p1363" }). If the signature is DER, the verify fails and so does the test.

That's in our verification suite now. No network, no database:

ok VAPID self-test passes
ok REGRESSION: a browser can decrypt the payload
ok final-record delimiter is 0x02
ok each send uses a fresh ephemeral key
ok REGRESSION: signature is raw R||S (64 bytes), not DER
ok REGRESSION: the signature actually verifies
ok aud is the push service ORIGIN, not the full endpoint
ok an oversized payload is rejected rather than truncated

Every line marked REGRESSION is a bug we actually had.

What I'd take from this

Two things, and neither of them is about cryptography.

The toggle works now. If you turned push notifications on at some point and quietly wondered why you never heard from us — that was us. It's fixed.

Part of a broader audit of OneCeylon's notification system, which also turned up an ENUM restated by hand across seven migrations that disagreed with each other, and twenty-five call sites sending booking alerts under the notification type verification. Those are their own posts.

Liked this?
We are hiring two people.

A senior backend engineer to own the API, the data pipelines, and the platform underneath all of this — plus six months of paid applied ML research inside SerendAI.

See the two roles →
Also in the notebook
RESEARCH April 2026 · 10 min read

Teaching SerendAI to read a Sri Lankan pharmacy sign at 9pm.

From 43% to 86% on real travel photos — and the preprocessing trick that finally moved the numbers.

ENGINEERING April 2026 · 11 min read

Putting 5,000 travel questions on a map.

From a 14-second naïve query to 90ms — the engineering story behind OneCeylon's map-based question discovery.