importKey on a PEM secret: the Workers ES256 fix
Store an IdP's public key as a Worker secret and call crypto.subtle.importKey("spki", PEM, ...), and the call throws before it touches your token. The message is a TypeError about the second argument that names neither PEM nor base64: you passed a string, and importKey accepts BufferSource or a JsonWebKey object. Nothing downstream — signature check, exp, iss — ever runs.
I hit this on a service verifying ES256 access tokens at the edge with a P-256 key pasted from the IdP's JWKS screen. Below is the twelve-line decoder that fixes it and the verifier I shipped: algorithm pinned before any crypto, key cached per isolate, six token shapes that must be rejected. Everything numeric was executed here — Node v26.5.1 webcrypto, the real Workers engine via workerd 2026-10-15 (npm [email protected], compatibilityDate = "2026-09-01"), OpenSSL 3.5.6 — and every transcript is pasted from those runs.
TL;DR — what fails, what fixes it
- A PEM is text;
importKeywants bytes. The PEM string givesTypeError: Failed to execute 'importKey' on 'SubtleCrypto': parameter 2 is not of type 'BufferSource or JsonWebKey'.Passing just the base64 body changes nothing — still a string. - The fix: strip the envelope,
atob()the base64, copy into aUint8Array, pass those bytes.atobis a global in Workers and Node 22+, so nonode:bufferimport and no compat flag. A P-256 SPKI public key is 91 DER bytes; the correct call returnspublic/ECDSA/P-256. - Wording differs per runtime, the failure does not. The same call reads
parameter 2 is not of type 'BufferSource or JsonWebKey'underworkerdand2nd argument is not instance of ArrayBuffer, Buffer, TypedArray, or DataViewunder Node v26.5.1. Do not grep logs for one fixed sentence. TypeError= wrong shape,DataError= wrong bytes. Truncated DER →DataError: Invalid SPKI input.; the right DER imported under the wrong algorithm object →DataError: Input was not an RSA key.- A
JsonWebKeyobject is the other valid path.importKey("jwk", jwkObject, ...)works;importKey("spki", jwkObject, ...)throwsTypeError: Import data provided for buffer-based import formats must be a buffer source.; a JWK still in string form throws the PEMTypeError. - Pin
algbefore verifying. Measured here: an HS256 token MACed with the public-key bytes is accepted by a verifier that trustsheader.alg(true) and rejected by the one on this page (unsupported alg: HS256).
What importKey actually accepts
The signature is importKey(format, keyData, algorithm, extractable, keyUsages) and keyData is declared as BufferSource or JsonWebKey. BufferSource means an ArrayBuffer or a view over one — Uint8Array, DataView, a Node Buffer. A JavaScript string is neither; it is UTF-16 code units in the engine's heap. PEM is a transport envelope for exactly those bytes: a label line, base64, a footer, and the base64 decodes to the DER structure that format: "spki" names — SubjectPublicKeyInfo, the BEGIN PUBLIC KEY label that IdPs publish and openssl pkey -pubout writes. BEGIN RSA PUBLIC KEY carries PKCS#1 instead and will not import as "spki".
| Call (workerd 2026-10-15, compatibilityDate 2026-09-01) | Result |
|---|---|
importKey("spki", PEM, ...) | TypeError: ... parameter 2 is not of type 'BufferSource or JsonWebKey'. |
importKey("spki", base64Body, ...) | same TypeError |
importKey("spki", spkiDer, ...) | OK public/ECDSA/P-256 |
importKey("spki", spkiDer.subarray(0, 40), ...) | DOMException [DataError]: Invalid SPKI input. |
importKey("spki", spkiDer, { name: "RSASSA-PKCS1-v1_5", ... }, ...) | DOMException [DataError]: Input was not an RSA key. |
importKey("spki", jwkObject, ...) | TypeError: Import data provided for buffer-based import formats must be a buffer source. |
importKey("jwk", jwkObject, ...) | OK ECDSA |
importKey("jwk", JSON.stringify(jwk), ...) | same TypeError as the PEM |
Node v26.5.1 words three of those differently: the string case is 2nd argument is not instance of ArrayBuffer, Buffer, TypedArray, or DataView., truncated DER is DataError: Invalid keyData, the wrong algorithm object is DataError: Invalid key type. Same branches, different sentences — and the JWK case read Import data provided for "raw", "pkcs8", or "spki" import formats must be a buffer source. on workerd 2026-09-15 earlier the same day, so the text moves between builds. Match the error class, not the sentence.
The failure, in one file
Save this as repro.mjs and run it under any Node 22+ build: top-level await works in an ES module and crypto is a global. The PEM is a throwaway P-256 public key generated for this page.
#!/usr/bin/env bash
# Published block 1 (verbatim) — the failure everyone hits.
mkdir -p /tmp/wc-es256 && cd /tmp/wc-es256
cat > repro.mjs <<'JS'
// The IdP hands you a PEM. Workers secrets are strings. importKey is not.
const pem = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEGJM4QRkCj61cGYZN6eO0zOxPHA5t
Lr8vpNmV8Erp9xlMFVHVZyfdwEDbSKMvnCgS9o6NwFZli3SVNslpwtAP+Q==
-----END PUBLIC KEY-----`;
try {
const key = await crypto.subtle.importKey("spki", pem, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
console.log("imported:", key.algorithm.name);
} catch (err) {
console.log(`${err.constructor.name} [${err.name}]: ${err.message}`);
}
JS
node repro.mjs
Output on Node v26.5.1, from that exact block:
TypeError [TypeError]: Failed to execute 'importKey' on 'SubtleCrypto': 2nd argument is not instance of ArrayBuffer, Buffer, TypedArray, or DataView.
Note what did not happen: crypto.subtle never looked at the key material. The argument failed a type check, which is why the exception reads like a tooling annoyance instead of a configuration problem.
Decode the PEM yourself: twelve lines, no dependencies
This is the shipped decoder, not a sketch. It validates the envelope and returns the bytes plus the label, so a caller can refuse BEGIN RSA PUBLIC KEY before the import. The companion loadPublicKey is three lines: crypto.subtle.importKey("spki", der, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]) plus the label check.
const PEM_HEADER = /-----BEGIN ([A-Z0-9 ]+)-----/;
/** Decode a PEM document (public or private) into raw DER bytes. */
export function pemToDer(pem) {
const match = PEM_HEADER.exec(pem);
if (!match) throw new Error("not a PEM document: no -----BEGIN----- line");
const start = match.index + match[0].length;
const end = pem.indexOf("-----END", start);
if (end === -1) throw new Error("PEM document has no -----END----- line");
const body = pem.slice(start, end).replace(/[\s\r\n]+/g, "");
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) throw new Error("PEM body is not base64");
const bin = atob(body);
const der = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) der[i] = bin.charCodeAt(i);
return { label: match[1], der };
}
Four details are load-bearing. The slice starts at match.index, not at 0, because real PEMs arrive with a leading blank line or an OpenSSL Bag Attributes prefix — both are re-run in the self-test below and both decode to 91 bytes. atob is a global in Workers and Node 22+, so nothing here needs node:buffer or the compat-date discussion in the nodejs_compat post. The whitespace strip is /[\s
]+/g because PEMs arrive with CRLF, wrapped lines, or from a secrets manager with newlines collapsed into literal
sequences. And returning the label is what lets you say which envelope was wrong in the log line.
The verifier, and the self-test that keeps it honest
Order is the design. The algorithm comes from your code, never from the header; the label check and the import happen before any token byte is decoded; claims are validated only after the signature proves the payload authentic. The harness below mints its own P-256 key, wraps the exported SPKI in a PEM, and feeds the verifier one token per failure mode: tampered signature, alg: none, expired, wrong issuer, wrong audience, and an HS256 token MACed with the public-key bytes.
The whole file — pemToDer, loadPublicKey, verifyJwt and the harness — runs as one block under Node. It writes verify-es256.mjs plus a four-line driver, then runs the driver:
#!/usr/bin/env bash
# Published block 5 (verbatim) — the module + the self-test, executed under Node.
mkdir -p /tmp/wc-es256 && cd /tmp/wc-es256
cat > verify-es256.mjs <<'JS'
// verify-es256.mjs — ES256 JWT verification for Cloudflare Workers.
// No node: builtins: the same module runs under workerd and under Node 22+ webcrypto.
const PEM_HEADER = /-----BEGIN ([A-Z0-9 ]+)-----/;
/** Decode a PEM document (public or private) into raw DER bytes. */
export function pemToDer(pem) {
const match = PEM_HEADER.exec(pem);
if (!match) throw new Error("not a PEM document: no -----BEGIN----- line");
const start = match.index + match[0].length;
const end = pem.indexOf("-----END", start);
if (end === -1) throw new Error("PEM document has no -----END----- line");
const body = pem.slice(start, end).replace(/[\s\r\n]+/g, "");
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) throw new Error("PEM body is not base64");
const bin = atob(body);
const der = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) der[i] = bin.charCodeAt(i);
return { label: match[1], der };
}
/** Import the SubjectPublicKeyInfo PEM your IdP gave you as a verify-only ECDSA P-256 key. */
export async function loadPublicKey(pem) {
const { label, der } = pemToDer(pem);
if (label !== "PUBLIC KEY") throw new Error(`wrong PEM label: ${label} (need SubjectPublicKeyInfo)`);
return crypto.subtle.importKey("spki", der, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
}
const b64urlToBytes = (segment) => {
if (!/^[A-Za-z0-9_-]*$/.test(segment)) throw new Error("malformed base64url segment");
return Uint8Array.from(atob(segment.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
};
/** Verify an ES256 JWT against an imported key. Returns the claims, or throws. */
export async function verifyJwt(token, key, { issuer, audience, clockTolerance = 60 } = {}) {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("malformed JWT: expected 3 segments");
const [headerB64, payloadB64, sigB64] = parts;
let header, claims;
try {
header = JSON.parse(new TextDecoder().decode(b64urlToBytes(headerB64)));
claims = JSON.parse(new TextDecoder().decode(b64urlToBytes(payloadB64)));
} catch {
throw new Error("malformed JWT: header or payload is not JSON");
}
// Pin the algorithm before any crypto: this is what kills the HS256 confusion attack.
if (header.alg !== "ES256") throw new Error(`unsupported alg: ${header.alg}`);
const signatureOk = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
key,
b64urlToBytes(sigB64),
new TextEncoder().encode(`${headerB64}.${payloadB64}`),
);
if (!signatureOk) throw new Error("signature verification failed");
const now = Math.floor(Date.now() / 1000);
if (typeof claims.exp !== "number") throw new Error("claim exp missing");
if (now > claims.exp + clockTolerance) throw new Error("token expired");
if (typeof claims.nbf === "number" && now + clockTolerance < claims.nbf) throw new Error("token not yet valid");
if (issuer && claims.iss !== issuer) throw new Error(`bad issuer: ${claims.iss}`);
if (audience && claims.aud !== audience) throw new Error(`bad audience: ${claims.aud}`);
return claims;
}
/** Throwaway-key self-test: every token below is one that must be rejected. */
export async function selfTest() {
const out = [];
const b64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const kp = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
const spkiDer = new Uint8Array(await crypto.subtle.exportKey("spki", kp.publicKey));
const oneLine = btoa(String.fromCharCode(...spkiDer));
const pem = `-----BEGIN PUBLIC KEY-----\n${oneLine}\n-----END PUBLIC KEY-----\n`;
out.push(`pemToDer -> ${pemToDer(pem).label} ${pemToDer(pem).der.length} bytes`);
// A PEM that reaches you with junk in front of it: a leading blank line, or openssl -clcerts output.
const prefixed = `\nBag Attributes\n friendlyName: idp-signing\n${pem}`;
out.push(`pemToDer with a leading blank line -> ${pemToDer("\n" + pem).der.length} bytes`);
out.push(`pemToDer with an openssl Bag Attributes prefix -> ${pemToDer(prefixed).label} ${pemToDer(prefixed).der.length} bytes`);
const key = await loadPublicKey(pem);
out.push(`key imported -> ${key.type} ${key.algorithm.name} ${key.algorithm.namedCurve}, usages ${key.usages.join(",")}`);
const mint = async (header, claims) => {
const h = b64url(new TextEncoder().encode(JSON.stringify(header)));
const p = b64url(new TextEncoder().encode(JSON.stringify(claims)));
const sig = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, kp.privateKey, new TextEncoder().encode(`${h}.${p}`)));
return `${h}.${p}.${b64url(sig)}`;
};
const good = await mint({ alg: "ES256", typ: "JWT" }, { sub: "user_1", iss: "https://idp.example", aud: "c2cz", exp: 4102444800 });
out.push(`ES256 signature -> ${b64urlToBytes(good.split(".")[2]).length} bytes (raw r||s, not DER)`);
const opts = { issuer: "https://idp.example", audience: "c2cz" };
const check = async (label, token, o = opts) => {
try {
out.push(`${label} -> ${JSON.stringify(await verifyJwt(token, key, o))}`);
} catch (err) {
out.push(`${label} -> rejected: ${err.message}`);
}
};
await check("verify(valid token)", good);
const s = good.split(".");
s[2] = (s[2][0] === "A" ? "B" : "A") + s[2].slice(1);
await check("verify(tampered signature)", s.join("."));
await check("verify(alg none)", `${b64url(new TextEncoder().encode(JSON.stringify({ alg: "none" })))}.${good.split(".")[1]}.`);
await check("verify(expired)", await mint({ alg: "ES256", typ: "JWT" }, { sub: "user_1", iss: "https://idp.example", aud: "c2cz", exp: 1000000000 }));
await check("verify(wrong issuer)", await mint({ alg: "ES256", typ: "JWT" }, { sub: "user_1", iss: "https://evil.example", aud: "c2cz", exp: 4102444800 }));
await check("verify(wrong audience)", await mint({ alg: "ES256", typ: "JWT" }, { sub: "user_1", iss: "https://idp.example", aud: "other-app", exp: 4102444800 }));
// The confused token: HS256-MACed with the public key bytes, header claiming alg HS256.
const h = b64url(new TextEncoder().encode(JSON.stringify({ alg: "HS256", typ: "JWT" })));
const p = b64url(new TextEncoder().encode(JSON.stringify({ sub: "admin", exp: 4102444800 })));
const mac = await crypto.subtle.importKey("raw", spkiDer, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const forged = new Uint8Array(await crypto.subtle.sign("HMAC", mac, new TextEncoder().encode(`${h}.${p}`)));
out.push(`HS256 confusion token -> ${forged.length}-byte MAC over the public-key bytes`);
await check("verify(HS256 confusion token)", `${h}.${p}.${b64url(forged)}`);
// 86 base64url chars carry 64 bytes + 4 padding bits. Change the padding bits only, then a data bit.
const ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
const fixed = new Uint8Array(64).map((_, i) => (i * 7) % 256);
const enc = b64url(fixed);
const v = ALPHA.indexOf(enc.at(-1));
const padOnly = enc.slice(0, -1) + ALPHA[(v & 0b110000) | ((v + 1) & 0b001111)];
const dataBit = enc.slice(0, -1) + ALPHA[v ^ 0b100000];
const same = (x, y) => {
const a = b64urlToBytes(x);
const b = b64urlToBytes(y);
return a.length === b.length && a.every((byte, i) => byte === b[i]);
};
out.push(`${enc.length} chars, last char ${enc.at(-1)} (${v}): padding-bit change '${padOnly.at(-1)}' -> decoded bytes ${same(enc, padOnly) ? "identical" : "different"}`);
out.push(`data-bit change '${dataBit.at(-1)}' -> decoded bytes ${same(enc, dataBit) ? "identical" : "different"}`);
return out.join("\n");
}
JS
cat > selftest.mjs <<'JS'
import { selfTest } from "./verify-es256.mjs";
console.log(await selfTest());
JS
node selftest.mjs
Output on Node v26.5.1, pasted from that run:
pemToDer -> PUBLIC KEY 91 bytes
pemToDer with a leading blank line -> 91 bytes
pemToDer with an openssl Bag Attributes prefix -> PUBLIC KEY 91 bytes
key imported -> public ECDSA P-256, usages verify
ES256 signature -> 64 bytes (raw r||s, not DER)
verify(valid token) -> {"sub":"user_1","iss":"https://idp.example","aud":"c2cz","exp":4102444800}
verify(tampered signature) -> rejected: signature verification failed
verify(alg none) -> rejected: unsupported alg: none
verify(expired) -> rejected: token expired
verify(wrong issuer) -> rejected: bad issuer: https://evil.example
verify(wrong audience) -> rejected: bad audience: other-app
HS256 confusion token -> 32-byte MAC over the public-key bytes
verify(HS256 confusion token) -> rejected: unsupported alg: HS256
86 chars, last char Q (16): padding-bit change 'R' -> decoded bytes identical
data-bit change 'w' -> decoded bytes different
Three lines carry the weight. pemToDer with an openssl Bag Attributes prefix -> PUBLIC KEY 91 bytes is a real input shape — secrets pasted from a terminal that carried openssl -clcerts headers — and it is the case a naive pem.slice(0, …) decode fails. verify(alg none) -> rejected: unsupported alg: none is the JWT library bug that has shipped for a decade. And the confused token — an HS256 MAC over the public-key bytes with alg: HS256 in the header — is rejected identically; a verifier that reads alg from the attacker's header accepts it, which I measured as true in this same lab.
The same module on the Workers runtime
Node webcrypto and the Workers runtime are different implementations, so "works locally" proves nothing about the edge. workerd is the actual engine, installs from npm without an account, and takes a Cap'n Proto config. The block installs it, embeds the module next to the worker, and serves the self-test over HTTP:
#!/usr/bin/env bash
# Published block 6 (verbatim) — the same module under the real Workers runtime.
mkdir -p /tmp/wc-es256 && cd /tmp/wc-es256
cat > config.capnp <<'CAPNP'
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [ (name = "main", worker = .mainWorker) ],
sockets = [ (name = "http", address = "127.0.0.1:19948", http = (), service = "main") ],
);
const mainWorker :Workerd.Worker = (
modules = [
(name = "worker.js", esModule = embed "worker.js"),
(name = "verify-es256.mjs", esModule = embed "verify-es256.mjs"),
],
compatibilityDate = "2026-09-01",
);
CAPNP
cat > worker.js <<'JS'
import { selfTest } from "./verify-es256.mjs";
export default {
async fetch() {
return new Response((await selfTest()) + "\n", { headers: { "content-type": "text/plain" } });
},
};
JS
npm install workerd
./node_modules/.bin/workerd serve config.capnp &
WPID=$!
for i in $(seq 1 60); do curl -s -o /dev/null --max-time 1 http://127.0.0.1:19948/ && break; sleep 0.5; done
curl -s http://127.0.0.1:19948/
kill "$WPID"
Report from curl, verbatim (the npm install lines above it are environment noise; the installed engine reports workerd 2026-10-15):
pemToDer -> PUBLIC KEY 91 bytes
pemToDer with a leading blank line -> 91 bytes
pemToDer with an openssl Bag Attributes prefix -> PUBLIC KEY 91 bytes
key imported -> public ECDSA P-256, usages verify
ES256 signature -> 64 bytes (raw r||s, not DER)
verify(valid token) -> {"sub":"user_1","iss":"https://idp.example","aud":"c2cz","exp":4102444800}
verify(tampered signature) -> rejected: signature verification failed
verify(alg none) -> rejected: unsupported alg: none
verify(expired) -> rejected: token expired
verify(wrong issuer) -> rejected: bad issuer: https://evil.example
verify(wrong audience) -> rejected: bad audience: other-app
HS256 confusion token -> 32-byte MAC over the public-key bytes
verify(HS256 confusion token) -> rejected: unsupported alg: HS256
86 chars, last char Q (16): padding-bit change 'R' -> decoded bytes identical
data-bit change 'w' -> decoded bytes different
Byte-for-byte the same verdicts as Node, plus the number that decides the runtime question: the ES256 signature is 64 bytes in both — the raw r||s form WebCrypto uses. OpenSSL 3.5.6 signing the same input with openssl dgst -sha256 -sign produced 71 bytes, because OpenSSL emits a DER SEQUENCE wrapper. Verify a DER signature with WebCrypto and every token fails with signature verification failed while the key looks innocent.
Wire it into Hono with a cached key
Import once per isolate. The measured cost is small — importKey on a JWK 0.020 ms/call, verify with a cached key 0.140 ms/call over 200 iterations each — but the discipline matters more than the microseconds: the import is the step that can fail after a redeploy with a bad secret. Cache the promise, drop it on rejection, and never import at module scope, so a broken secret fails on the first request with a log line instead of breaking startup (the module-scope rules live in this post).
import { Hono } from "hono";
import { verifyJwt, loadPublicKey } from "./verify-es256";
type JwtClaims = { sub: string; [key: string]: unknown };
type Env = {
Bindings: { PUBLIC_KEY: string; IDP_ISSUER: string; IDP_AUDIENCE: string };
Variables: { claims: JwtClaims };
};
// One import per isolate, not one per request: importKey ~0.02 ms, verify ~0.14 ms (measured).
let cachedKey: Promise<CryptoKey> | undefined;
function signingKey(pem: string): Promise<CryptoKey> {
if (!cachedKey) {
cachedKey = loadPublicKey(pem.replace(/\\n/g, "\n"));
cachedKey.catch(() => {
cachedKey = undefined; // never cache a rejected promise: a fixed secret must recover
});
}
return cachedKey;
}
const app = new Hono<Env>();
app.use("/api/*", async (c, next) => {
const [scheme, token] = (c.req.header("authorization") ?? "").split(" ");
if (scheme !== "Bearer" || !token) return c.json({ error: "missing bearer token" }, 401);
try {
c.set("claims", await verifyJwt(token, await signingKey(c.env.PUBLIC_KEY), {
issuer: c.env.IDP_ISSUER,
audience: c.env.IDP_AUDIENCE,
}));
} catch (err) {
// Log the reason, return a uniform body: never tell the caller which check failed.
console.error("jwt_rejected", err instanceof Error ? err.message : err);
return c.json({ error: "invalid token" }, 401);
}
await next();
});
export default app;
Caching the CryptoKey across requests is safe because a key handle is not an I/O object. Streams, bodies and channels are what the runtime's cross-request rules target — that is a different error with a different fix. Set the secret with the standard CLI (wrangler secret put PUBLIC_KEY < idp-public.pem); I did not exercise wrangler in this lab, so that one is the CLI contract, not a measured transcript.
Pitfalls that cost me an afternoon
- Base64 padding bits are real bits. 86 base64url characters carry 64 bytes plus four padding bits, so a one-character edit at the end can be a no-op. Measured on a fixed 64-byte buffer: changing the last char
Q→R(padding bits only) leaves the decoded bytes identical, whileQ→w(a data bit) changes them. A verifier "rejecting" the first kind of edit was never given a different token — tamper tests must move a data bit. exportKeyon a non-extractable key. Verifiers should import withextractable: false, and then cannot re-export:DOMException [InvalidAccessError]: Attempt to export non-extractable ECDSA key.Cache the JWK in KV only if you imported extractable; a verify-only key should stay opaque.- A key and a verify call must agree on the algorithm. Calling
verifywith ECDSA parameters against an RSA key throwsDOMException [InvalidAccessError]: Requested algorithm "ECDSA" does not match this CryptoKey's algorithm "RSASSA-PKCS1-v1_5".Supporting RS256 and ES256 in one Worker means one imported key per algorithm, selected by the pinnedalg. - Secrets with escaped newlines, and logs that leak. A PEM stored through a CI variable often arrives as one line of literal
text, which the decoder rejects at the base64 guard — normalise before importing, and log the label rather than the key.
Verification and environment
Executed on 2026-10-15 on this host: Linux 6.17 (arm64), Node v26.5.1, npm 11.17.0, workerd 2026-10-15 (npm [email protected]) with compatibilityDate = "2026-09-01", OpenSSL 3.5.6, curl 8.14.1. Every block on this page was extracted from the draft and run verbatim; the self-test transcripts are the stdout of those runs, reproduced identically on a second run. The P-256 key in the first block is a throwaway public key generated for this page.
Last verified: 2026-10-15.