Skip to content
Allin

Generating an Application Secret Key Correctly

Published 8/12/2026 · 13 min read · Text & language tools

Daniel Okonkwo

Daniel OkonkwoFront-end developer and tech writer at Allin

Web performance · File formats

Checked against 4 sources

View profile
In short

Three questions settle it. First: what random source does it call? A sound generator calls the platform's cryptographic randomness — crypto.getRandomValues in a browser, secrets in Python, random_bytes in PHP. An unsound one calls Math.random, which is documented as unsuitable for security: V8 implements it with xorshift128+, and a handful of consecutive outputs are enough to reconstruct the internal state and predict the rest. This tool passes: its randStr calls rngInt, which fills a Uint32Array with crypto.getRandomValues; the Math.random branch only runs when crypto is missing, which no browser is. Second: how much entropy does the output carry? Multiply the length by the base-two logarithm of the alphabet size. The Django character set here is exactly the fifty characters Django's own get_random_secret_key uses — lowercase letters, digits and !@#$%^&*(-_=+) — so a 50-character key carries 50 × log2(50) = 282 bits, against the roughly 128 bits usually taken as the floor. The alphanumeric option gives 297 bits. The WordPress side generates the eight wp-config constants at 64 characters from a 92-character set, which is byte-for-byte wp_generate_password with both special-character flags on: 418 bits each. Third: where does the key go next? Neither answer helps once it has been pasted into a chat, a ticket or a commit — a secret that has been shared is spent, and the only fix is to generate a new one and rotate it.

How to tell a sound secret-key generator from an unsound one, using this one as a worked example: which random source it calls, how to compute the entropy yourself, and what actually breaks the day you rotate the key.

Question one: which random source

Every language has two random number generators and they are not interchangeable. One is fast, deterministic from a seed, and meant for shuffling a playlist or jittering a retry. The other is slower, draws from the operating system's entropy pool, and is meant for anything an attacker would like to guess. In JavaScript they are Math.random and crypto.getRandomValues; in Python, random and secrets; in PHP, mt_rand and random_bytes. Using the first where the second belongs is the single most common way a key generator is wrong.

The reason Math.random is disqualifying is not that its output looks patterned — it does not. It is that the generator is a small deterministic machine with a state you can recover. V8, the engine in Chrome and Node, uses xorshift128+, a 128-bit state advanced by shifts and exclusive-ors. Given a short run of consecutive outputs, that state can be reconstructed, and once it is, every future value is known and every past one can be replayed. The engine also has no obligation to seed it unpredictably, and browsers have historically seeded it from things an attacker can observe or influence.

This tool calls the right one. Reading the code from the button inwards: the generate handler builds a string character by character with randStr, randStr picks each index with rngInt, and rngInt allocates a Uint32Array of one and fills it with crypto.getRandomValues. There is a Math.random fallback, but it sits behind a test for the existence of crypto and crypto.getRandomValues, and getRandomValues is available in every browsing context including insecure ones — unlike crypto.subtle, which is not. In practice the fallback is unreachable code.

Question two: how many bits, and how to count them yourself

The arithmetic fits on one line. If every character is drawn independently and uniformly from an alphabet of n characters, each character carries log2(n) bits, and a key of length L carries L × log2(n). That is all. There is no correction for "uses symbols" or "looks complicated": the alphabet size and the length are the only inputs, which is why a long lowercase key beats a short one full of punctuation.

Apply it to the default here. The Django character set is the twenty-six lowercase letters, the ten digits, and the fourteen characters !@#$%^&*(-_=+) — fifty in total, with no duplicates, which matters because a repeated character would quietly shrink the effective alphabet. log2(50) is 5.644 bits per character, so the default 50-character key carries 282.2 bits. Switching to the alphanumeric option raises the alphabet to sixty-two and the key to 297.7 bits; the full option raises it to seventy-six and 312.4 bits. All three are enormous next to the 128-bit figure normally quoted as sufficient — twenty-three characters of the Django set would already clear it.

The WordPress side is measured the same way. It emits the eight wp-config constants — the four keys and the four salts — at 64 characters each, drawn from a 92-character set: the sixty-two letters and digits, then !@#$%^&*() and then -_ []{}<>~`+=,.;:/?|. That is character for character what WordPress's own wp_generate_password produces with both special-character arguments set, and 64 × log2(92) is 417.5 bits per constant. Two details of that set are worth noticing: it contains a space, which is legal and slightly startling, and it contains neither a single quote nor a backslash, which is why the generated value drops into define('AUTH_KEY', '…'); without any escaping.

The modulo bias, stated honestly

There is one flaw in the implementation and it is worth stating precisely, because a vague statement about it would be more alarming than the flaw. To turn a random 32-bit number into an index into a 50-character alphabet, the code takes the remainder: index equals value modulo 50. Two raised to the thirty-second is 4 294 967 296, and that does not divide by fifty. The remainder is forty-six, so forty-six of the fifty characters can be produced by 85 899 346 of the possible values and the other four by 85 899 345.

The favoured characters are therefore more likely than the others by a ratio of one part in 85 899 345, about 1.2 in a hundred million. For the 92-character WordPress set the remainder is twelve and the ratio is about 2.1 in a hundred million. A generator that fixed this would use rejection sampling — draw again whenever the value falls in the ragged tail — and would gain a difference no measurement of a 50-character key could ever detect. It is a footnote, not a warning.

The bias vanishes entirely when the alphabet size is a power of two, because the division is then exact — a 64-character or 256-value alphabet has none at all. That is the tidy reason a great deal of key material is expressed in hexadecimal or base64: the arithmetic is exact and there is nothing left to argue about.

What the key is used for, which decides what rotating it breaks

Django's documentation lists exactly what depends on SECRET_KEY: every session, unless you use the cache session backend and have overridden the default session auth hash; every message stored in a cookie; every password-reset token; and every use of the signing framework that does not pass a key of its own. The consequence is spelled out in the same page — when a key stops being the SECRET_KEY and is not listed in SECRET_KEY_FALLBACKS, all of that is invalidated at once.

In practice that means everyone is logged out and every password-reset link already in an inbox stops working — which is exactly what you want after a leak, and exactly what you do not want on a Tuesday afternoon for no reason. SECRET_KEY_FALLBACKS is the documented way to have both: put the new key in SECRET_KEY, keep the old one in the fallback list long enough for outstanding sessions and reset links to age out, then remove it. Django is explicit that user passwords are not derived from the key and are unaffected either way.

WordPress offers no equivalent grace period, and its own documentation says so in one sentence: you can change the keys at any time to invalidate all existing cookies, and that means every user has to log in again. There is no fallback list; the change is immediate and total. The same page notes that the four keys are required and the four salts are only recommended, because WordPress will generate salts itself if none are defined — which is one more reason to paste all eight rather than half of them.

Where a well-generated key still gets burned

The generator is the easy part. Almost every real incident starts afterwards. A key ends up in a settings file that is committed, in a screenshot pasted into a ticket, in a chat message to a colleague, in a build log, in an environment variable printed by a debug endpoint, or in a container image layer that outlives the branch it came from. None of those is a cryptographic failure; all of them end the key's usefulness just as completely.

The version-control case deserves its own sentence because the instinct is wrong. Deleting the line and committing the deletion does not remove the key: it is still in the history, still in every clone anyone has made, and still in every fork and every mirror of the repository. Rewriting the history helps only if you also invalidate every copy that has already been fetched, which you cannot. The only sound response is to treat the key as public and rotate it.

Generating in the browser is fine, and better than it sounds. The key is produced by the page you already have open, from the operating system's own randomness, and it exists only in that tab until you copy it. Nothing is sent anywhere, because nothing needs to be: drawing fifty indices and joining fifty characters is a few lines of code, and a server would only add a place where the key existed that you cannot inspect.

Every option the two generators offer, with the entropy computed as length × log2(alphabet)
SettingAlphabetEntropy
Django default, 50 characters50 — lowercase, digits, !@#$%^&*(-_=+)282.2 bits — byte-for-byte Django's own get_random_secret_key
Django, alphanumeric, 50 characters62 — letters of both cases and digits297.7 bits — safe to put in a shell variable, nothing to quote
Django, full set, 50 characters76 — the alphanumeric set plus the fourteen symbols312.4 bits — 30 bits more than the default, for no practical gain
Django, 100 characters50 — the same set, twice the length564.4 bits — the setting exists, the need for it does not
WordPress full set, 64 characters, eight constants92 — matches wp_generate_password with both special flags417.5 bits each; contains a space, no quote and no backslash
WordPress alphanumeric only, 64 characters62 — letters and digits381.1 bits — 36 bits less, and nothing to escape anywhere
Any generator calling Math.randomWhatever it saysEffectively zero — the state is recoverable from a few outputs
Django Secret Key GeneratorGenerate a cryptographically secure Django SECRET_KEY with your choice of length and character set.Try the tool

Frequently asked questions

Is a key generated in my browser as safe as one generated on a server?
Safer, if anything. crypto.getRandomValues draws from the same operating-system entropy a server would use, so the randomness is of the same quality. The difference is the key's travel: generated in the page, it exists in one tab and goes nowhere until you copy it, whereas a server-side generator means the key existed on a machine you do not control, in a process you cannot inspect, possibly in a log line you will never see. Reload the page or close the tab and the value is gone. The one thing a browser cannot protect you from is the clipboard, and whatever you paste it into next.
How long does a secret key really need to be?
Take the framework's default and stop thinking about it. Django's own function returns 50 characters, which from its 50-character alphabet is 282 bits; WordPress emits 64 characters from 92, which is 418. The threshold usually cited for a symmetric secret is around 128 bits, and 23 characters of the Django alphabet already exceed it. The extra length costs nothing and buys nothing, and the settings offering 80 or 100 characters are there for completeness rather than because anyone needs them. Length only becomes the wrong lever when the alphabet is tiny: a 20-character key made of digits alone carries 66 bits, less than half of what a 20-character key from the Django set carries.
Does the modulo bias mean I should not use this generator?
No. The bias is real and it is 1.2 parts in a hundred million on the Django alphabet: forty-six of the fifty characters are reachable from 85 899 346 of the four billion possible 32-bit values and the other four from 85 899 345. Spread over fifty independent draws, it moves the entropy of a key by a quantity far below any unit anyone would print. What the bias is genuinely useful for is as a marker when you are reading someone else's generator: seeing a bare remainder tells you nobody thought about rejection sampling, which is a hint about the rest of the code, and seeing rejection sampling tells you somebody did. Neither is a reason to reject a key that is already 282 bits deep.
What breaks the moment I change the key?
In Django: every session that is not held in the cache backend, every message stored in a cookie, and every password-reset token, including the links already sitting in people's inboxes. The documentation states it plainly and gives the mitigation — move the old key into SECRET_KEY_FALLBACKS, leave it there long enough for outstanding sessions and reset links to expire naturally, then delete it. User passwords are not derived from the key and are untouched. In WordPress there is no fallback list: changing any of the eight constants invalidates every existing cookie immediately, and its documentation says in as many words that all users will have to log in again. Plan a key change for a quiet hour unless you are responding to a leak, in which case do it now and accept the logouts.
I committed my key to git. Is deleting the line enough?
No. A commit that deletes a line leaves the previous commit intact, so the key is still in the history and still in every clone, fork and mirror that already exists. Rewriting the history removes it from your copy, and from anyone who fetches afterwards, but not from anyone who fetched before — and on a public repository you have to assume that includes automated scanners, which watch new commits for exactly this. Treat the key as published: generate a new one, put the old one in the fallback list if your framework has one, and remove it once the transition window has passed. Then make the leak structurally impossible by keeping the value in an environment variable or a secret store and committing only a file that names the variable.

Articles you may find interesting

All guides

Related tools

Everything here describes what these four tools do today, checked by running their own code against the exact inputs printed in each article — not what a standard obliges them to do. Where a tool gets a case wrong, that is said plainly rather than worked around, and nothing was changed to make an article read better. Two consequences follow. Run any transform over a copy first and compare both ends: a text tool that deletes something is silent about it. And treat a secret the moment it leaves the page as a secret you have shared — pasting one into a chat, a ticket or a repository burns it however well it was generated.

Sources

Spotted a mistake in this article?