How a background worker proves it is allowed to take your jobs

A shared token in a header is the right amount of auth for a machine, not a user. The three things that make it safe: fail closed when unset, compare in constant time, and scope the token to one job.

A worker, a key, and a lock

A background worker does not need a login. It needs one secret, sent in a header, compared in constant time — and a server that refuses to run at all if that secret was never configured. That last part is the one people skip, and it is the one that turns a small mistake into an open endpoint.

This follows on from the Postgres job queue. That article ended with a worker calling POST /api/worker/claim and being handed a job. Anyone on the internet can send that request. So: why does the server believe this caller?

Why not the auth you already have

The obvious move is to reuse the user login. It is already built, it already works, and it is wrong here for a plain reason: there is no user.

The worker is a process on a machine. It has no inbox for a password reset, no browser to hold a session cookie, no human to complete a second factor. Fitting it into a login flow means inventing a fake user account, storing that user's password on the worker, and now you have a human-shaped credential — one that can log into your admin panel — sitting in a config file on a laptop.

OAuth client credentials are the formally correct answer, and they are a real option once several parties are involved. For one worker you control, talking to one server you control, it is a token exchange, a refresh loop and an expiry clock bolted onto a problem that does not have those parts.

What the worker actually needs is to prove it is the worker. One shared secret does that.

Worker sends a shared token; the server replies 503, 401 or 200

The shape

The worker sends a header:

POST /api/worker/claim HTTP/1.1
Host: animator.cstsolution.com
x-worker-token: 6f2a9c...

The server compares it to WORKER_TOKEN from its environment. Match, and the worker gets one job. No match, 401.

import crypto from 'node:crypto'

export function requireWorkerToken(req, res, next) {
  const expected = process.env.WORKER_TOKEN

  // Fail closed. No token configured means the queue is not open for business.
  if (!expected) {
    return res.status(503).json({ error: 'worker queue disabled' })
  }

  const given = req.get('x-worker-token') || ''
  if (!timingSafeMatch(given, expected)) {
    return res.status(401).json({ error: 'unauthorized' })
  }

  next()
}

That is nearly the whole thing. The three decisions worth explaining are the ones that are easy to get subtly wrong.

1. Unset means off, not open

if (!expected) return res.status(503).json({ error: 'worker queue disabled' })

Read what the alternative does. If you write the obvious thing —

if (given !== expected) return res.status(401)   // ← both undefined when unset

— then on a server where WORKER_TOKEN was never set, given is undefined and expected is undefined, and the check passes for everyone. A missing environment variable silently turns your job queue into a public API.

This is not hypothetical: environment variables go missing on exactly the days you are not paying attention — a new container, a renamed secret, a fresh staging box that was never given the value. Failing closed converts that from a security incident into an obvious 503 that someone notices in a minute.

2. Compare in constant time

=== on strings stops at the first byte that differs. That difference is measurable over the network, and it leaks the secret one character at a time: a guess sharing the first three characters takes fractionally longer to reject than one that fails immediately.

Early-exit comparison leaks the secret through response timing
function timingSafeMatch(a, b) {
  const ab = Buffer.from(a)
  const bb = Buffer.from(b)
  // timingSafeEqual throws on length mismatch, which would itself leak length.
  // Hash both first: always the same 32 bytes, whatever went in.
  const ah = crypto.createHash('sha256').update(ab).digest()
  const bh = crypto.createHash('sha256').update(bb).digest()
  return crypto.timingSafeEqual(ah, bh)
}

Hashing first is the part people miss. crypto.timingSafeEqual throws if the two buffers are different lengths, so comparing raw input directly means you either crash or branch on length — and branching on length tells an attacker how long your token is. Hash both sides and you always compare 32 bytes against 32 bytes.

Is a timing attack over the public internet realistic against a 32-byte random token? Honestly: not very. Network jitter swamps the signal, and the search space is enormous. But the fix is four lines, has no downside, and you do not have to keep re-deciding whether this endpoint is the exception.

3. One token, one job

A worker token should let a worker do worker things and nothing else. In our setup it gates exactly three routes — claim a job, complete a job, fail a job — and nothing about billing, users or admin.

That scoping is what makes the blast radius survivable. If the token leaks, an attacker can claim jobs and post nonsense results. That is bad. It is a different category of bad from a credential that could read your user table.

Generating and storing it

# 32 random bytes, hex. Not a password, not a word, not "worker-prod-2024".
openssl rand -hex 32

Put it in the server's environment and in the worker's environment. Nowhere else — not in the repo, not in the client bundle, not in a screenshot of your terminal.

Ours lives in a file outside the repository entirely, next to the SSH and database credentials, and is read by the worker at startup. The rule that keeps it simple: the token appears in exactly two places, and both are files that git has never heard of.

Check it yourself

Prove the fail-closed behaviour in about a minute. Save as server.js:

import http from 'node:http'
import crypto from 'node:crypto'

const hash = (s) => crypto.createHash('sha256').update(Buffer.from(s)).digest()
const match = (a, b) => crypto.timingSafeEqual(hash(a), hash(b))

http.createServer((req, res) => {
  const expected = process.env.WORKER_TOKEN
  if (!expected) { res.writeHead(503).end('queue disabled\n'); return }
  if (!match(req.headers['x-worker-token'] || '', expected)) {
    res.writeHead(401).end('unauthorized\n'); return
  }
  res.writeHead(200).end('job 41\n')
}).listen(3999, () => console.log('listening on 3999'))
# 1. No token configured — the queue refuses to serve anyone.
node server.js &
curl -s -o /dev/null -w "unset:  %{http_code}\n" localhost:3999
kill %1

# 2. Configured. Wrong token, then right token.
WORKER_TOKEN=$(openssl rand -hex 32) node server.js &
curl -s -o /dev/null -w "wrong:  %{http_code}\n" -H "x-worker-token: nope" localhost:3999
kill %1

You should see 503, then 401. Now delete the if (!expected) line and run the first case again: it returns 200. That single line is the difference between a queue that is closed and one that is wide open.

Where this goes next

This is how the worker behind the Game Asset Generator authenticates when it pulls work from the queue. The same pattern secures the runner in Workflow Builder, where the thing being protected is a user's automation rather than an image.

Next: moving large files to a server that has no git remote, no scp and only a password prompt — chunked transfer, checksums, and two failure modes that cost me an hour each.