How to generate secure tokens
The right call in nine languages, the wrong one sitting next to it in the same standard library, and what to do with the token after you have generated it.
Updated 2026-09-05
One rule, then the syntax
Every language on this page ships two random number generators. One is a fast statistical generator meant for simulation, sampling and jitter. The other reads from the operating system entropy pool and is meant for secrets. They are usually adjacent in the documentation, they often have near-identical names, and choosing the wrong one produces output that looks identical and passes every statistical test you can throw at it.
That is the entire problem. There is no observable difference in the output, so the mistake survives code review, survives QA, and is only found by reading the import line. The list below is the import line, per language.
Underneath, all of them reach the same place: getrandom on Linux, BCryptGenRandom or ProcessPrng on Windows, and the arc4random or SecRandomCopyBytes family on macOS. The kernel is doing the work. Your job is to call the function that asks it.
| Language | Use | Do not use | Why the wrong one is tempting |
|---|---|---|---|
| JavaScript | crypto.getRandomValues, crypto.randomBytes | Math.random | It is shorter and it is what every tutorial shows. |
| Python | secrets | random | random.choice reads more naturally than secrets.choice. |
| Go | crypto/rand | math/rand, math/rand/v2 | The import paths differ by four characters. |
| Rust | getrandom, rand OsRng | SmallRng, a self-seeded StdRng | thread_rng is already a CSPRNG, which makes the boundary fuzzy. |
| PHP | random_bytes, random_int | rand, mt_rand, str_shuffle, uniqid | uniqid sounds like it guarantees something. It is a timestamp. |
| Java | java.security.SecureRandom | java.util.Random, Math.random | One package separator apart in the import. |
| C# | RandomNumberGenerator | System.Random, Random.Shared | Random.Shared is the modern-looking API. |
| Ruby | SecureRandom | Kernel#rand, Random.new | rand is a global with no import. |
| Shell | openssl rand, /dev/urandom | $RANDOM | $RANDOM needs no dependency and has 15 bits. |
How long the token should be
NIST SP 800-63B-4 sets a hard floor rather than a target: "If the authentication secret is less than 64 bits long, the verifier SHALL implement a rate-limiting mechanism". Read that as a statement about what 64 bits does and does not buy. It is enough when an attacker gets a limited number of tries against a live service, and it is not enough on its own. OWASP's session management guidance lands in the same place, recommending a session identifier of at least 128 bits carrying at least 64 bits of entropy.
In practice 128 bits is the answer for almost everything, for a reason that has nothing to do with the threat model: it is the point where the calculation stops being worth doing. At 128 bits there is no rate limit to reason about, no growth curve to project, no argument with a reviewer, and the cost is a handful of extra characters. Below that you are trading a few bytes for an ongoing obligation to think about it.
Go higher only when there is a specific reason. A JWT signing key for HS256 should be 256 bits because that is the output size of the HMAC. A long-lived, offline-verifiable credential deserves 256 bits because you cannot rate-limit what you cannot see being attacked.
One arithmetic trap worth naming. A 22-character base64url string drawn uniformly from the 64-symbol alphabet carries exactly 132 bits. A 22-character base64url string produced by encoding 16 random bytes carries 128 bits, because the final character can only take four of the sixty-four values. They look identical and they are not the same value, so be clear about which one you generated.
| Target | Hex | base64url | Alphanumeric (62) | Crockford base32 |
|---|---|---|---|---|
| 64 bits | 16 chars | 11 chars | 11 chars | 13 chars |
| 112 bits | 28 chars | 19 chars | 19 chars | 23 chars |
| 128 bits | 32 chars, exact | 22 chars | 22 chars | 26 chars |
| 256 bits | 64 chars, exact | 43 chars | 43 chars | 52 chars |
Store a hash, not the token
A bearer token in your database is a password in your database. Treat it the same way: store a hash, compare hashes, and make the plaintext visible exactly once, at creation.
The difference from a user password is that you may use a fast hash, and you should. Password hashing schemes are slow on purpose because human-chosen passwords have low entropy and need the work factor to compensate. A 128-bit random token has no low-entropy structure to attack, so a plain SHA-256 is sufficient and lets you look the token up by its hash on every request without a measurable cost. Running bcrypt on every API call to verify a 128-bit random string buys nothing and costs you throughput.
A salt is unnecessary for the same reason: the input is already unique and unguessable, so there is no dictionary to precompute and no rainbow table that helps. What you do want is a lookup key. Store the hash of the whole token and, if you need prefix lookup, store a short non-secret identifier alongside it rather than a prefix of the secret.
This is also what makes leak detection possible. If the plaintext only ever existed in the response that created it, a token found in a log or a repository can be traced to the moment it escaped.
Give the token a visible prefix that identifies its type and your service, in the style of sk_live_ or ghp_. It is not a security control, but it makes secret scanners work and it tells whoever finds it in a log what they are looking at.
Compare in constant time
The comparison that verifies a token must not return early on the first mismatched byte. A standard string equality does, and the timing difference is small but measurable across enough requests. An attacker who can measure it recovers the value one byte at a time, which turns an infeasible search into a linear one.
Every runtime ships the fixed-time comparison. In Node it is crypto.timingSafeEqual, which requires both buffers to be the same length and throws otherwise. Python has hmac.compare_digest. Go has crypto/subtle.ConstantTimeCompare. PHP has hash_equals. Java has MessageDigest.isEqual. .NET has CryptographicOperations.FixedTimeEquals. Ruby has OpenSSL.fixed_length_secure_compare.
If you are hashing tokens at rest and looking them up by hash, the database index does the matching and the timing question largely goes away. It comes back the moment you fetch a row and compare a secret in application code, which is what most implementations end up doing at least once.
Expire it, and be able to revoke it
A token with no expiry is a permanent credential, and permanent credentials accumulate. Set a lifetime at creation, store it, and check it on the same code path that checks the hash.
Short-lived access tokens with a separate refresh path are the standard shape for a reason: it bounds the value of a stolen token without forcing a user to reauthenticate constantly. For machine credentials the equivalent is rotation with an overlap window, so a new key can be deployed before the old one stops working.
Revocation needs to be a single operation you can perform under pressure. If revoking a leaked key requires editing configuration in three places, it will not happen quickly enough. This is the argument against stateless self-verifying tokens for anything long-lived: a signed token that carries its own authority cannot be recalled before it expires unless you maintain the deny list you were trying to avoid.
Doing this in your own code
JavaScript
// Browser and Node 19+, identical API.
const bytes = crypto.getRandomValues(new Uint8Array(32)); // 256 bits
// Node also has the callback-free classic:
import { randomBytes } from "node:crypto";
randomBytes(32).toString("base64url"); // 43 chars, 256 bits
randomBytes(16).toString("hex"); // 32 chars, exactly 128 bits
// Wrong, and the single most common version of wrong:
// Math.random().toString(36).slice(2)
// V8's Math.random is xorshift128+ with a 128-bit state seeded once per
// context. It is a statistics tool, not a secret source, and it yields
// about 52 usable bits per call no matter how many you concatenate. crypto.getRandomValues() throws QuotaExceededError above 65,536 bytes in one call, so large requests must loop.
Python 3.6+
import secrets
secrets.token_urlsafe(32) # 32 random bytes rendered base64url
secrets.token_hex(32) # 32 random bytes rendered as 64 hex chars
secrets.token_bytes(32) # the raw bytes
# Unbiased choice from your own alphabet:
alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
"".join(secrets.choice(alphabet) for _ in range(22))
# Wrong: the random module is a Mersenne Twister. Its state is fully
# recoverable from 624 consecutive 32-bit outputs.
# random.choice(alphabet)
# random.SystemRandom() is fine, but secrets exists so you do not have
# to remember which of the two you reached for. token_urlsafe(n) takes a byte count, not a character count. The string it returns is about 4n/3 characters long.
Go
import (
"crypto/rand"
"encoding/base64"
)
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err) // in Go 1.24+ Read never returns an error
}
token := base64.RawURLEncoding.EncodeToString(b)
// Go 1.24 added a one-liner that returns a base32 string carrying
// at least 128 bits of randomness:
// token := rand.Text()
// Wrong: math/rand and math/rand/v2 are not cryptographic, and the
// import paths differ by four characters. Grep your repo for
// "math/rand" before you trust it. The whole Go mistake is an import line. crypto/rand and math/rand expose confusingly similar surfaces.
Rust
// Straight from the OS, no dependency beyond getrandom = "0.3":
let mut buf = [0u8; 32];
getrandom::fill(&mut buf).expect("OS RNG unavailable");
// Via the rand crate, rand = "0.9". OsRng is fallible here, so it
// exposes try_fill_bytes rather than fill_bytes.
use rand::rngs::OsRng;
use rand::TryRngCore;
let mut buf = [0u8; 32];
OsRng.try_fill_bytes(&mut buf).expect("OS RNG unavailable");
// Note the Rust-specific nuance: rand::rng() IS a CSPRNG
// (ChaCha12 seeded from the OS), so it is not the classic mistake.
// The mistake in Rust is SmallRng, or any StdRng you seeded yourself
// from a value you can reproduce. rand has changed its module layout across releases: 0.8 had rand::thread_rng(), gen_range and an infallible OsRng; 0.9 has rand::rng(), random_range and a fallible OsRng. Pin the version and read that version's docs rather than a blog post.
PHP 7+
$bytes = random_bytes(32);
$token = rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
// Unbiased integer in a range, rejection-sampled internally:
$i = random_int(0, strlen($alphabet) - 1);
// Wrong, all of them: rand(), mt_rand(), str_shuffle(), array_rand(),
// uniqid(). uniqid() is a microsecond timestamp in hex - it is not
// random at all, and uniqid('', true) appends output from the combined
// linear congruential generator, which the manual says is still not
// cryptographically secure. random_bytes() and random_int() throw on entropy failure rather than degrading. Do not catch and fall back.
Java
import java.security.SecureRandom;
import java.util.Base64;
SecureRandom rng = new SecureRandom();
byte[] b = new byte[32];
rng.nextBytes(b);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(b);
// Wrong: java.util.Random (a 48-bit LCG) and Math.random(), which is
// backed by exactly that.
// Caveat worth knowing: SecureRandom.getInstanceStrong() maps to a
// blocking source on some Linux configurations and can stall a cold
// container. Plain "new SecureRandom()" is the right default. C# / .NET
using System.Security.Cryptography;
byte[] b = RandomNumberGenerator.GetBytes(32);
string token = Convert.ToBase64String(b)
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
// Unbiased index into your own alphabet, rejection-sampled by the BCL:
int i = RandomNumberGenerator.GetInt32(0, alphabet.Length);
// Wrong: System.Random, including Random.Shared.
// RNGCryptoServiceProvider is obsolete from .NET 6 (SYSLIB0023) -
// use the static RandomNumberGenerator methods above. Ruby
require "securerandom"
SecureRandom.urlsafe_base64(32) # 32 bytes, base64url, no padding
SecureRandom.hex(16) # 32 hex chars, exactly 128 bits
SecureRandom.bytes(32) # raw
# Wrong: Kernel#rand and Random.new, both Mersenne Twister. Shell
openssl rand -base64 32
openssl rand -hex 32
head -c 32 /dev/urandom | base64
# base64url from the shell, padding stripped:
head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '='
# Wrong: $RANDOM is 15 bits, seeded from the PID and the clock, so it
# has at most 32,768 values per draw. Also wrong: date | md5sum. /dev/urandom is the correct device on every modern Linux. /dev/random offers no additional security once the pool is initialised and can block.
Questions
Is a UUID version 4 an acceptable API token?
Technically yes. It carries 122 random bits from a cryptographic source, which is above the 128-bit rule of thumb by less than a bit and well past any practical threshold. The reasons to prefer a purpose-built token are ergonomic: 36 characters with hyphens is long, the format invites someone to parse it as an identifier, and it does not carry a prefix that tells a secret scanner what it is. A UUID version 7 is not acceptable, because 48 of its bits are a readable timestamp and only 74 are random.
Should I use bcrypt or Argon2 to hash API tokens?
No, use SHA-256. Slow hashes exist to compensate for low-entropy inputs. A 128-bit random token has no structure to guess, so the work factor buys nothing and you pay it on every single request. Reserve the slow hashes for values a human chose.
How many bytes should I ask for to get a 32-character token?
It depends on the encoding, and the byte-count APIs are the usual source of confusion. Hex is two characters per byte, so 16 bytes gives 32 characters and exactly 128 bits. base64url is roughly four characters per three bytes, so 24 bytes gives 32 unpadded characters and 192 bits. Python's secrets.token_urlsafe and Ruby's SecureRandom.urlsafe_base64 both take a byte count and return a longer string than the number you passed them.
Does it matter that /dev/urandom is not /dev/random?
Not on any modern kernel. Once the pool has been initialised at boot, urandom and random draw from the same CSPRNG; the only difference is that random may block waiting for entropy estimates that do not correspond to any real weakening of the output. Use urandom, or better, use your language's wrapper, which handles the early-boot case for you.
Is it safe to generate production secrets in a browser tab?
It is safe if the value never leaves the tab, and that is a property you can check rather than take on trust. Open the network panel, generate, and confirm nothing was sent. The relevant risk is not the randomness quality, since crypto.getRandomValues reaches the same operating system source your server would, but whether the page transmits or stores what it produced.