Our app is two deployments that have to trust each other. The Next.js app runs as serverless functions and owns the database and the business rules. A small WebSocket server runs as a long-lived process and owns the clock, because a serverless function cannot hold a fifteen-second timer. Neither can do a quiz night alone.
So they call each other. The app tells the WebSocket server that a question was launched; the WebSocket server, when that question's time is up, tells the app to close it. Two directions, two HTTP surfaces, and the same question on both: how does each one know the caller is the other one and not a stranger.
The answer is a shared secret in a header, which is unglamorous and correct for this shape. The interesting part is the six lines that compare it, and the order they have to be in.
The comparison
Here is the inbound check on the Next side, in full:
export function isInternalRequest(req: NextRequest): boolean {
const expected = process.env.WS_INTERNAL_SECRET
const given = req.headers.get('x-internal-secret')
if (!expected || !given) return false
const a = Buffer.from(given)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
Three decisions, each of which is wrong in at least one obvious alternative form.
Why not given === expected. String comparison in V8 returns as soon as it finds a differing byte. Comparing a wrong secret that shares the first ten characters takes measurably longer than one that differs at the first. Over enough requests, an attacker who can time your responses recovers the secret one byte at a time. This is the textbook case for timingSafeEqual, which always reads both buffers to the end.
Why the length check has to come first. timingSafeEqual throws a RangeError when its arguments have different byte lengths. Not false. Throws. So you cannot simply hand it whatever arrived in the header, because the first person to send a one-character secret gets a 500 out of your route instead of a 401, and your error monitor fills up with other people's probing.
Which means the length comparison is mandatory, and it is not constant time with respect to length. A caller can learn how many bytes our secret is.
That trade is fine, and it is worth being explicit about why rather than just accepting it. The length of a secret is not the secret. Knowing ours is N bytes reduces the search space from "all strings" to "all strings of length N", which for any sane secret is no reduction worth the name. And you cannot hide it anyway: the throw is observable, so a wrong-length guess is distinguishable from a right-length wrong guess no matter which order you write the code in. Given that, returning false quickly beats throwing.
Why if (!expected || !given) return false. Without it, a deploy that forgot to set the variable would compare undefined to undefined. Depending on how you coerce, that is either a crash or, far worse, a pass. Missing configuration must deny. On a route whose only protection is this function, the failure mode of "secret not configured" has to be "nobody gets in", not "everybody does".
The same six lines live in the other repo
The WebSocket server has its own copy:
function secretMatches(given: string | string[] | undefined): boolean {
const expected = process.env.WS_INTERNAL_SECRET;
if (typeof given !== 'string' || !expected) return false;
const a = Buffer.from(given);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
Nearly identical, with one real difference: Node's raw http module gives you string | string[] | undefined for a header, because a header can legally appear twice. typeof given !== 'string' rejects the array case rather than stringifying it, which would turn two headers into "secret,secret" and fail the length check anyway, just less clearly.
The duplication is deliberate. The two packages have separate build roots and separate dependency trees, and a shared module between them would be a build-system problem in exchange for deleting six lines. Six lines of security code is exactly the amount I am willing to write twice and read carefully twice.
This is the only thing guarding those routes
Worth saying loudly, because it is easy to assume a framework is helping here. Our proxy matcher excludes /api entirely:
'/((?!_next/static|…|webhook|api|monitoring).*)'
No session refresh, no auth gate, no route allowlist runs on /api/internal/close-question. It is a public URL on a public domain, and isInternalRequest is the whole of its defence. That is the correct arrangement, because the caller is a server with no cookies and no Supabase session, and running a cookie-based auth gate in front of it would do nothing but add latency. But it does mean the function above is load bearing in a way that a function sitting behind two other layers is not.
The routes themselves are thin on purpose. Check the secret, parse the body, call the engine:
if (!isInternalRequest(req)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await readTimerBody(req)
if (!body || body.questionIndex === undefined) {
return NextResponse.json({ error: 'Body must be { sessionId: string, questionIndex: number }' }, { status: 400 })
}
And readTimerBody returns null rather than throwing on anything malformed, including a body that is not JSON at all, so a 400 is a 400 and never a 500.
The status code that is a 200 on purpose
This one surprised me into changing my mind. The close route answers a request it refused to apply with 200:
// 200 even on logical errors (already closed, etc.) so the caller does not retry.
if (!result.success) return NextResponse.json({ ok: false, error: result.error })
My instinct was 409. But think about who is calling. The timer asks to close question 3, and the answer "question 3 was already closed, by the host, a second before you asked" is the system working. It is the single most common non-trivial response this route gives. A 4xx tells the caller's retry logic that something went wrong and it should try again, and there is nothing to try again; the move has been made by somebody else.
So: non-2xx means "your request did not arrive or we fell over, please retry". 200 with ok: false means "your request arrived, was understood, and the answer is no". The caller's log line for the second case is not applied: invalid_transition, at log level info, which is right, because on a busy quiz night it happens constantly and it means nothing is wrong.
The fallback variable that made a server talk to itself
The best bug in this corner of the codebase was not in the secret handling. It was in the address.
The WebSocket server needs the Next app's URL to call those two routes. There is also a WS_INTERNAL_URL in the environment, which is the WebSocket server's own address, used by the Next app to reach it. Two URL variables, pointed in opposite directions, with similar names, in a system where both processes read from a shared config.
The WebSocket server's fetch helper had a fallback: use NEXTJS_INTERNAL_URL, or if that is unset, WS_INTERNAL_URL. Which meant a deploy that only set one of them produced a server that POSTed /api/internal/close-question to itself, got a 404, and logged a failure.
The consequence is not subtle. Nothing closes questions. The clock reaches zero, the timer fires, the request 404s, and the question stays open. Every question, on every session, until somebody looks at the logs. A countdown that reaches zero and does nothing is a quiz night over.
The fix is to delete the fallback:
// No fallback to WS_INTERNAL_URL: that is this server's own address, and a
// deploy that copied it here used to POST to itself, get a 404, and leave
// every question open forever. A missing variable should say so.
const nextjsUrl = process.env.NEXTJS_INTERNAL_URL;
const secret = process.env.WS_INTERNAL_SECRET;
if (!nextjsUrl || !secret) {
console.error('[gameClock] NEXTJS_INTERNAL_URL or WS_INTERNAL_SECRET not set. The game clock cannot act');
return;
}
Same principle as if (!expected) return false, applied to configuration rather than credentials. A default is a guess, and a guess about which server you are talking to is worse than a loud absence. The general version: fall back to a different value if you like, never to a different destination.
Dedupe, because both ends can ask
One more line that earns its place. The WebSocket server keys every in-flight internal call and refuses to start a second one with the same key:
async function postInternal(path: string, body: object, key: string): Promise<void> {
if (inFlight.has(key)) return;
With keys like close:${sessionId}:${questionIndex}. There is a three-second self-healing sweep that looks for questions whose time has expired, and it will happily find the same expired question on two consecutive passes if the first close is still in flight. The app's own transition guards already make a duplicate close harmless, so this is not correctness, it is not sending a request we know the answer to.
Why any of this matters at a quiz night
All of this plumbing exists so that one screen and forty phones agree about what moment it is. The big screen display is the host's own view put on the venue's TV, which means there is no second client to keep in sync, but there are still every player's phones, and the thing that keeps them aligned with the screen is a timer in one process talking over HTTP to the rules in another. The feature list is a fair description of what that buys: players scan a code and type a name, with no account and no app, which is only possible because none of the trust in the system lives with the player.
Two servers, one secret, six lines. Write the length check first, deny when unconfigured, and never let a URL fall back to the wrong server.
Top comments (0)