A Go worker minting reset tokens emits repeats after every restart. How do you find the cause?
answer
- the symptom names the mechanism
- a cryptographic source has no seed to rebuild
- one command lists the binary's packages
- check the error branch, not just the imports
- a single-process test would never have caught it
basics
~10 sRepeats aligned with restarts mean the token source is deterministic and rebuilt at startup, almost always a seeded math/rand/v2 generator. Switch the path to crypto/rand and invalidate every token already issued.
solid answer
~50 sThe symptom names the cause: something is re-created identically at process start, and `crypto/rand` cannot be, because it never holds a seed. So the token path is running a `math/rand/v2` generator — `rand.New(rand.NewPCG(...))` or `NewChaCha8` with a constant seed, or a helper someone ported from another language that seeds "once at startup". A silently ignored error is the other route in: a `crypto/rand` call whose error branch quietly uses a seeded generator. Find it with `go list -deps ./cmd/worker | grep '^math/rand'`, then read every use on the minting path. Fix by switching to `crypto/rand.Text()` and deleting the fallback outright rather than making it better. Prove it with a test that runs the mint function in two fresh processes and asserts no overlap — that is the check the original code never had. Then treat every outstanding token as compromised: invalidate them all and make users request a new link.
code
go · 10 linesimport (
"math/rand/v2"
"strconv"
)
var gen = rand.New(rand.NewPCG(0x5eed, 0x5eed))
func resetToken() string {
return strconv.FormatUint(gen.Uint64(), 36)
}go deeper
Recognise the tell: output that repeats identically after a restart means something deterministic is being rebuilt at startup, and a secret should never be produced that way.
Trace it concretely. Know that a seeded math/rand/v2 generator or an error-branch fallback are the two ways in, and that listing the binary's package dependencies tells you in one command whether the family is linked at all.
Show the full response: find it, fix it without softening the fix, prove it with a test that spans two processes, and invalidate the credentials already issued. Explain why seeding better or hashing the output is not a fix.
Decide how this stops recurring — one package owns secret generation, its imports are constrained mechanically, and the incident response for predictable credentials is written down before you need it rather than improvised during an outage.
## Read the symptom first "Repeats after every restart" is unusually informative. It says the generator has state, that the state is rebuilt at process start, and that it is rebuilt the *same way* each time. A cryptographic source cannot behave like this: `crypto/rand` holds no seed you supply and draws from the operating system, so two processes started a second apart produce unrelated output. A statistical generator behaves exactly like this the moment its seed is a constant, a configuration value, or anything the environment supplies identically on each boot. Before reaching for tools, that reasoning already narrows the search to one import. ## Find it mechanically ``` $ go list -deps ./cmd/resetworker | grep '^math/rand' math/rand/v2 ``` `go list -deps` prints the full transitive package list for a binary, so this answers "does anything this worker links in use the statistical generator" without reading a line of code. If it comes back empty, the cause is elsewhere and you have ruled out the whole family in one command. If it prints a match, walk the callers until you reach the minting function. The two shapes you are looking for: ```go var gen = rand.New(rand.NewPCG(0x5eed, 0x5eed)) // math/rand/v2 func resetToken() string { return strconv.FormatUint(gen.Uint64(), 36) } ``` and the quieter one, where the intent was correct and an error branch undid it: ```go if _, err := crand.Read(b); err != nil { // "this can never happen, but just in case" fillFromSeededGenerator(b) } ``` The second is the more dangerous, because a reviewer skimming for imports sees `crypto/rand` at the top of the file and moves on. It also fails in a lumpier way: it only produces repeats when the branch fires, which is why intermittent duplication is worth chasing even when the happy path looks right. ## Where the habit comes from This is characteristically a porting bug. In several other ecosystems the idiomatic setup is a global generator seeded once at startup, and the reviewer's rule there is "make sure you seeded it". Someone carries the rule across, seeds a Go generator from a config value or a constant so that tests are reproducible, and ships. In Go both halves of that habit are wrong: the top-level `math/rand` functions have been randomly seeded since Go 1.20 and `math/rand/v2` has no top-level `Seed` at all, so there is nothing to remember to do — and none of it was ever the reason the package is unsuitable for secrets. ## Fix, and do not soften the fix Replace the generator with `crypto/rand.Text()`, which returns a 26-character token in one call and cannot fail. Delete the fallback branch entirely rather than improving it — there is no correct content for it, and since Go 1.24 `crypto/rand.Read` does not return an error to trigger it. Resist two tempting non-fixes: - **Adding entropy to the seed.** A better seed does not change what the generator promises; an observer of a few outputs still recovers the stream. - **Hashing the output.** A hash of a predictable value is a predictable value. ## Prove it, do not assert it The defect survived review because nothing tested the property. Add a test that exercises the generator across two *fresh processes* — the state that repeats only repeats on a restart, so a single test binary calling the function a thousand times will pass happily on the broken code. Running the mint function in a subprocess twice and comparing the two sets is the check that would have caught it, and it keeps catching it if someone reintroduces a seeded generator later. Back it with a structural guard: keep secret generation in one small package, and make an import of `math/rand` or `math/rand/v2` from that package a build failure or a review rule. Mechanical guards outlast the memory of the incident. ## Then handle the tokens already out there The engineering fix stops new bad tokens; it does nothing about the ones already issued. Every outstanding reset token generated by the deterministic path must be treated as known to an attacker: invalidate them all, force affected users to request a new link, and check the audit log for reset completions that do not correspond to a request. Shipping the code change and leaving the old tokens valid is the half-fix that turns a bug into an incident. ## Related symptoms worth ruling out If `go list -deps` finds nothing, look for a token that is *derived* rather than generated — a hash of the user id and a timestamp, a database sequence, a value cached per user — or for a test double left wired into the production build, such as a replaced `crypto/rand.Reader` behind a build tag. All three produce repeatable output while every import looks correct.
- The code really does use crypto/rand and the tokens still repeat. What else would you look at?A token that is derived rather than generated — a hash of the user id, a timestamp, or a database sequence — will repeat while every import looks right. So will a test double left wired in: a replaced `crypto/rand.Reader`, or a deterministic source selected by a build tag that also matches the production build. Check what actually feeds the string, not just which package is imported.
- How do you prove the fix rather than assert it?Run the mint function in two fresh processes and compare the outputs, because the bug only manifests across a restart and a single-process loop will pass on the broken code. Keep that test in the suite, and add a structural guard so the package that mints secrets cannot import math/rand at all.
- What do you do about the reset links already sitting in users' inboxes?Treat them as compromised. Invalidate every outstanding token, make users request a new link, and review the audit log for completed resets that do not match a request. The code change only stops new bad tokens; leaving the old ones valid keeps the vulnerability open for as long as they live.
- Someone proposes keeping the seeded generator but seeding it from crypto/rand at startup. Is that acceptable?No. The restart symptom goes away, which makes it look fixed, but the underlying property does not change: the generator still exposes its state through its output, so an attacker who requests a few of their own reset links can compute other users' tokens. Seeding a statistical generator from a cryptographic one does not upgrade its guarantees.
saying these in an interview costs you the question
- Blames chance collisions in a 128-bit space
- Adds more entropy to the seed instead of changing packages
- Hashes the predictable value and calls it fixed
- Only fixes generation and leaves issued tokens valid
- Claims a restart could change crypto/rand output
- Verifies with a single-process loop that cannot reproduce it