Skip to main content

How Safe Can a Six-Character Redeem Code Be?

· 19 min read
Bankware Global Engineering

Suppose we need to generate redeem codes made of exactly six uppercase letters and digits.

36^6 = 2,176,782,336
log2(36^6) = approximately 31.02 bits

More than 2.1 billion strings are possible. At first glance, that seems large enough to stop worrying about both duplicates and guessing. Once we design an actual issuer, however, we discover that this single number hides four different problems.

  • Format: a code must contain exactly six ASCII characters from 0-9 and A-Z.
  • Uniqueness: two distinct issuances must never receive the same code.
  • Prediction resistance: observing one code should not make the next code easy to calculate.
  • Validity: a well-formed string is not necessarily a code that was issued and can still be redeemed.

This article is not about public promotional codes shared by many people. It focuses on bearer-style redeem codes that are generated once per issuance and let whoever possesses the string claim a benefit. Mathematics that solves one of these problems does not automatically solve the others.

Collision-free generation, hiding issuance order, and deciding whether a redemption is valid are separate requirements.

First design: SecureRandom and a database UNIQUE constraint

The most natural starting point is to draw six characters from a cryptographically strong random number generator. In Java, security-sensitive randomness should use java.security.SecureRandom rather than java.util.Random.

private static final char[] ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();

private static final SecureRandom RNG = new SecureRandom();

static String randomCode() {
char[] code = new char[6];
for (int i = 0; i < code.length; i++) {
code[i] = ALPHABET[RNG.nextInt(ALPHABET.length)];
}
return new String(code);
}

SecureRandom provides a cryptographically strong random number generator. nextInt(36) selects uniformly from 0 through 35. Applying % 36 directly to an arbitrary random byte can introduce modulo bias and should be avoided.

Let the database make the final decision

Random generation alone does not make collisions impossible. Put a UNIQUE constraint on the code column and generate a fresh candidate when a conflict occurs.

A preliminary SELECT cannot prevent a concurrency race. Two transactions can both observe that a code is absent and then try to INSERT it. The database, not an application-side precheck, must make the final decision atomically.

With PostgreSQL, rather than catching a uniqueness exception and attempting to continue inside an already failed transaction, we can use ON CONFLICT DO NOTHING.

INSERT INTO redeem_code (code, status, expires_at)
VALUES (:code, 'ACTIVE', :expires_at)
ON CONFLICT (code) DO NOTHING
RETURNING id;

If no row is returned, generate a new random candidate and retry a bounded number of times. Once MAX_RETRIES is exhausted, fail the issuance explicitly instead of looping forever.

for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
String candidate = randomCode();
OptionalLong insertedId = repository.tryInsert(candidate, expiresAt);

if (insertedId.isPresent()) {
return candidate;
}
}

throw new IllegalStateException("redeem-code space is too busy");

A collision sometime in the past is not the same as a collision on the next attempt

The Birthday Problem approximates the probability that at least one collision has occurred after generating n random candidates.

p ≈ 1 - exp(-n(n-1) / (2N))
N = 36^6
Cumulative candidatesProbability that at least one collision has occurred
10,000approximately 2.27%
50,000approximately 43.69%
55,000approximately 50.08%
100,000approximately 89.94%

The 90% figure at only 100,000 candidates looks alarming, but it means that at least one collision has occurred at some point in the past. If n codes currently occupy the namespace, the probability that the next candidate collides is n/N. Even with 100,000 occupied codes, that probability is only about 0.00459%.

The expected number of attempts until success is:

1 / (1 - n/N)

When namespace occupancy is low, randomness plus a UNIQUE constraint is therefore a simple and practical design. We should not interpret the cumulative birthday probability as if retry cost were already high. As the namespace fills, however, the probability of a collision on each new attempt—and the retry cost—really does increase.

Second design: Sequence and Base36

To remove collisions from the generation step itself, convert a value from a database sequence or another concurrency-safe ID allocator to Base36.

private static final long DOMAIN_SIZE = 2_176_782_336L;

static String base36(long id) {
if (id < 0 || id >= DOMAIN_SIZE) {
throw new IllegalArgumentException("ID outside six-char domain");
}

return String.format("%6s", Long.toString(id, 36))
.replace(' ', '0')
.toUpperCase(Locale.ROOT);
}

The result is regular and easy to recognize.

0 -> 000000
1 -> 000001
35 -> 00000Z
36 -> 000010

For 0 <= id < 36^6, this maps every ID to a distinct code. There is no need to look up duplicate candidates or retry after a collision.

The sequence does not need to be gapless. Database sequences can leave holes because of rollbacks, caching, and recovery. Redeem-code issuance needs unique, in-domain IDs shared by every issuer in the namespace, not gapless IDs. Gaps still consume domain capacity and must be included in capacity planning.

Base36 is not encryption

This solves uniqueness but exposes issuance order. Anyone who sees 0000AZ can immediately infer that 0000B0 may come next. Observed codes can also reveal an approximate issuance count.

Having no duplicates and being difficult to guess are different requirements.

The input range is part of the contract. The largest six-character Base36 value is ZZZZZZ, and the input range is 0 through 36^6 - 1. Silently applying modulo after that range is exhausted makes old codes reappear. The code length, alphabet, or a disjoint namespace must be expanded before exhaustion.

Third design: Modular permutation

A simple way to preserve the uniqueness of a sequence while scrambling output order is an affine modular mapping.

codeNumber = (id * A + B) mod N
N = 36^6 = 2,176,782,336

This function is a permutation of the domain if:

gcd(A, N) = 1

The reason is that A has an inverse modulo N. If f(x) = f(y), then:

A(x - y) ≡ 0 (mod N)

Multiplying both sides by the inverse of A gives x ≡ y (mod N). Because both values lie in 0..N-1, x = y.

The domain factors as:

36^6 = 2^12 * 3^12

Therefore, any A that is divisible by neither 2 nor 3 is coprime to N. B has no effect on the one-to-one property.

static final long N = 2_176_782_336L;
static final long A = 1_000_000_007L;
static final long B = 123_456_789L;

static String affineCode(long id) {
if (id < 0 || id >= N) {
throw new IllegalArgumentException("id outside domain");
}

long value = (id * A + B) % N;
return toSixDigitBase36(value);
}

A large multiplier is not a security parameter

A tempting mistake is to assume that a very large multiplier—or a large prime—makes prediction substantially harder.

The permutation condition depends on gcd(A,N)=1, not on the magnitude of A. A large prime does not make the output more uniform either. The linear structure remains visible:

f(id + 1) - f(id) ≡ A (mod N)

The numeric difference between outputs for consecutive inputs is constant. Even when A and B are hidden, consecutive samples or known input-output pairs can reveal the structure. Keeping constants secret is not the same as using a reviewed keyed cryptographic construction.

A modular permutation is useful for spreading data or scrambling IDs outside a security boundary. It is not enough to provide prediction resistance for a bearer credential exposed to untrusted users.

Fourth design: A keyed cryptographic permutation

To map issuance IDs one-to-one to codes while making the mapping look like a random permutation to anyone without the key, we need a PRP—a pseudorandom permutation.

0 <= sequential ID < 36^6
|
v
AES-based FF1, radix 36, length 6, fixed namespace
|
v
an exact six-character Base36 redeem code

Why truncating AES output does not work

AES has a 128-bit block size, while the redeem-code domain contains 36^6 elements. If AES outputs for distinct IDs are truncated or reduced modulo the six-character space, different ciphertexts can collapse to the same short value. Collisions return.

Format-preserving encryption such as FF1 builds a permutation inside a specified radix and length. NIST SP 800-38G specifies FF1 as an FPE method built on an approved block cipher.

This article uses:

  • radix: 36
  • length: 6
  • domain size: 36^6 = 2,176,782,336
  • key: a cryptographically generated AES key managed by version
  • tweak: a fixed namespace within one code space

The second public draft of NIST SP 800-38G Revision 1, published in 2025, raises the minimum FF1 domain size to 1,000,000. 36^6 exceeds that threshold. Revision 1, however, is still a public draft rather than a final publication at the time of writing.

Changing the tweak or key creates a new permutation

A tweak does not need to be secret. To retain one-to-one behavior across a global code space, however, it must remain fixed per namespace rather than changing for every issuance.

Changing either the key or the tweak creates a new permutation. Outputs from the new permutation can collide with codes already issued under an older one. Key rotation therefore needs explicit state and policy.

  • Store key_version on every issuance row.
  • Retain old keys securely for as long as old codes must be processed.
  • Keep a global UNIQUE(code) constraint as a final safeguard.
  • On a collision, allocate a new ID rather than encrypting the same ID again, or reserve non-overlapping code namespaces for versions.
  • If code-only stateless decoding is required, define how the decoder selects a key version.

Writing “rotate the key” in a design document does not automatically preserve global uniqueness.

The most important boundary: FF1 does not authenticate a code

The statement that FF1 is a permutation of the entire six-character domain has an easily overlooked consequence.

Every six-character Base36 string decrypts successfully to some ID.

The following equivalence does not hold:

successful FF1 decode
!= an actually issued code
!= a currently active code
!= a successful redemption

FF1 is not an authentication tag or a signature. It is a reversible transformation that hides issuance order. If a server grants a benefit merely because decode(code) returns an ID, every well-formed string submitted by an attacker will also produce an ID.

The redemption service must verify that the code or decoded ID was actually issued and then check status, expiry, usage count, and any bound account or order. In a database-backed design that looks up a row by code, the redemption path may not need decode at all.

It is difficult to preserve exactly six characters while also adding fully stateless authenticity. Adding an authentication tag increases the length; storing issuance state on the server is no longer stateless. The design should make this trade-off explicit.

Java implementation: Bouncy Castle FF1

The following example uses FPEFF1Engine from Bouncy Castle bcprov-jdk18on:1.85.2.

implementation("org.bouncycastle:bcprov-jdk18on:1.85.2")
import java.nio.charset.StandardCharsets;
import java.util.Objects;

import org.bouncycastle.crypto.fpe.FPEFF1Engine;
import org.bouncycastle.crypto.params.FPEParameters;
import org.bouncycastle.crypto.params.KeyParameter;

public final class RedeemCodeFf1 {
private static final char[] ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();

private static final int RADIX = 36;
private static final int LENGTH = 6;

public static final long DOMAIN_SIZE = 2_176_782_336L;

private final byte[] aesKey;
private final byte[] tweak;

public RedeemCodeFf1(byte[] aesKey, String namespace) {
Objects.requireNonNull(aesKey, "aesKey");
Objects.requireNonNull(namespace, "namespace");

if (aesKey.length != 16
&& aesKey.length != 24
&& aesKey.length != 32) {
throw new IllegalArgumentException("invalid AES key length");
}
if (namespace.isEmpty()) {
throw new IllegalArgumentException("namespace must not be empty");
}

this.aesKey = aesKey.clone();
this.tweak = namespace.getBytes(StandardCharsets.UTF_8);
}

public String encode(long id) {
requireInDomain(id);

byte[] input = toDigits(id);
byte[] output = new byte[LENGTH];

FPEFF1Engine ff1 = new FPEFF1Engine();
ff1.init(true, new FPEParameters(
new KeyParameter(aesKey), RADIX, tweak));
ff1.processBlock(input, 0, input.length, output, 0);

return toCode(output);
}

public long decode(String code) {
byte[] input = parseAsciiCode(code);
byte[] output = new byte[LENGTH];

FPEFF1Engine ff1 = new FPEFF1Engine();
ff1.init(false, new FPEParameters(
new KeyParameter(aesKey), RADIX, tweak));
ff1.processBlock(input, 0, input.length, output, 0);

return fromDigits(output);
}

private static byte[] toDigits(long value) {
byte[] digits = new byte[LENGTH];

for (int i = LENGTH - 1; i >= 0; i--) {
digits[i] = (byte) (value % RADIX);
value /= RADIX;
}
return digits;
}

private static long fromDigits(byte[] digits) {
long value = 0;

for (byte digit : digits) {
value = value * RADIX + (digit & 0xff);
}
return value;
}

private static String toCode(byte[] digits) {
char[] chars = new char[LENGTH];

for (int i = 0; i < LENGTH; i++) {
chars[i] = ALPHABET[digits[i] & 0xff];
}
return new String(chars);
}

private static byte[] parseAsciiCode(String code) {
if (code == null || code.length() != LENGTH) {
throw new IllegalArgumentException(
"code must be exactly 6 ASCII characters");
}

byte[] digits = new byte[LENGTH];

for (int i = 0; i < LENGTH; i++) {
char c = code.charAt(i);
int digit;

if (c >= '0' && c <= '9') {
digit = c - '0';
} else if (c >= 'A' && c <= 'Z') {
digit = c - 'A' + 10;
} else {
throw new IllegalArgumentException(
"code must contain only ASCII 0-9 and A-Z");
}

digits[i] = (byte) digit;
}
return digits;
}

private static void requireInDomain(long id) {
if (id < 0 || id >= DOMAIN_SIZE) {
throw new IllegalArgumentException("id outside domain");
}
}
}

FPEFF1Engine accepts an array of radix digits in 0..35, not the ASCII characters themselves. The conversion layer must enforce this boundary explicitly.

Using Character.digit(c, 36) directly accepts lowercase letters as well as some full-width characters and non-ASCII digits. If the database permits only ^[0-9A-Z]{6}$ while Java accepts a wider alphabet, the input and storage layers have different contracts. The example checks the ASCII ranges directly. If lowercase input is desirable, every entry point needs an explicit policy that normalizes it before validation.

And once again, the fact that decode() returns a value does not mean that the code was issued or remains active.

Properties to verify

  • Boundary IDs 0, 1, N-2, and N-1 succeed.
  • -1 and N are rejected.
  • decode(encode(id)) == id over a representative sample.
  • No duplicates appear over a realistically large sample.
  • Changing the key or namespace changes the output.
  • Lowercase, full-width characters, non-ASCII digits, and incorrect lengths are rejected.
  • Official FF1 test vectors and version-specific regressions run in CI.
  • Decoding an arbitrary well-formed code never redeems it before issuance state is checked.

For this draft, the example was compiled with JDK 21 and Bouncy Castle 1.85.2. IDs 0 through 99,999 produced 100,000 unique codes with successful round trips. This verifies basic properties of the example, not performance or production security.

The example creates a new engine for every call for clarity. A high-throughput implementation should verify the library's thread-safety contract and benchmark a safe instance-management strategy.

Key-management wording also needs care. This lightweight API uses raw AES key bytes inside the application process. A generic non-exportable KMS or HSM key does not automatically plug into this code. The system must either use a provider/HSM that directly supports FF1 or define how an envelope-encrypted data key or secret-manager key is delivered and protected in memory. A fixed test key must never become a production key.

Keep the database UNIQUE constraint even with FF1

FF1 is theoretically one-to-one under one key and fixed tweak, but that is not a reason to remove database safeguards. The following failures occur outside the mathematics.

  • an incorrect key_version
  • mixed namespaces
  • a range-check bug
  • a migration mistake
  • a collision with an older permutation during key rotation

UNIQUE(code) is the last line of defense that makes these failures visible.

CREATE TABLE redeem_code (
id BIGINT GENERATED BY DEFAULT AS IDENTITY
(START WITH 0 MINVALUE 0 MAXVALUE 2176782335 NO CYCLE)
PRIMARY KEY,
code VARCHAR(6) NOT NULL,
key_version SMALLINT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
expires_at TIMESTAMPTZ NOT NULL,
max_uses INTEGER NOT NULL DEFAULT 1,
used_count INTEGER NOT NULL DEFAULT 0,

CONSTRAINT uq_redeem_code UNIQUE (code),

CONSTRAINT ck_id_domain CHECK (
id >= 0 AND id < 2176782336
),

CONSTRAINT ck_code_format CHECK (
code ~ '^[0-9A-Z]{6}$'
),

CONSTRAINT ck_status CHECK (
status IN ('ACTIVE', 'CONSUMED', 'REVOKED')
),

CONSTRAINT ck_usage CHECK (
max_uses > 0
AND used_count >= 0
AND used_count <= max_uses
)
);

PostgreSQL automatically creates a unique B-tree index for a UNIQUE constraint. The code is NOT NULL, and the database enforces the same ASCII format. Because the value does not need fixed-width blank-padding semantics, this example uses VARCHAR(6) rather than CHAR(6).

Storing the plaintext code exposes active codes if the database is compromised. If the threat model requires otherwise, the system can store a lookup HMAC produced with a separate key. The HMAC key must be distinct from the FF1 key, and an unkeyed hash is not enough to stop offline enumeration of a 31-bit domain.

Change usage state atomically

Selecting a one-time code and updating it later can let concurrent requests all succeed. A conditional UPDATE should check the state and increment the usage count in one operation.

UPDATE redeem_code
SET used_count = used_count + 1,
status = CASE
WHEN used_count + 1 >= max_uses THEN 'CONSUMED'
ELSE status
END
WHERE code = :code
AND status = 'ACTIVE'
AND expires_at > CURRENT_TIMESTAMP
AND used_count < max_uses
RETURNING id, used_count, max_uses;

A redemption state transition succeeds only when a row is returned. Benefit delivery should happen in the same transaction when possible. If an external system must be called, use an explicit idempotency and outbox boundary to prevent duplicate delivery.

When a code is bound to an account or order, include that condition in the same atomic decision. Returning overly specific errors for “not found,” “expired,” and “already used” can provide attackers with a validity oracle, so response semantics belong in the design as well.

The brute-force limit of a 31-bit code space

FF1 makes it difficult to calculate the next code from observed codes. An attacker does not have to calculate the next code, however; they can submit six-character candidates directly.

Do not confuse the AES key strength with the size of the online code space. Even with a 128- or 256-bit AES key, there are only 36^6 output candidates—approximately 31.02 bits.

Attack targetSuccess probability per uniform guessExpected guesses without repeats
One specific active code1/N(N+1)/2, approximately 1.088 billion
Any of m active codesm/N(N+1)/(m+1)
Any of one million active codesapproximately 0.0459%approximately 2,177

Finding one specific code while visiting candidates without repetition takes about 1.088 billion attempts on average. If any active code is valuable, the attack becomes much easier as the number of simultaneously active codes grows.

A six-character Base36 code should therefore not serve as the only independent secret for a high-value benefit.

Why online defenses are necessary

OWASP ASVS 5.0 V6.6.3, which addresses out-of-band authentication codes, requires rate limiting and suggests considering at least 64 bits of entropy. It is not a blanket requirement for every redeem code, but it is a useful comparison for high-value bearer codes. Six Base36 characters provide only about 31 bits—less than half that amount.

If the format must remain six characters, combine several defenses.

  • Rate limits across account, device, session, campaign, and target order—not only IP address
  • Progressive delay, CAPTCHA, and temporary blocking after accumulated failures
  • The shortest expiry period the workflow permits
  • One-time use by default and atomic usage accounting
  • Binding to a specific account, order, campaign, or product
  • Error responses that do not reveal excessive validity detail
  • Application and analytics logs that do not retain plaintext codes
  • Alerts on failure rate, unique IP and device counts, account- and campaign-level attempts, and repeated patterns

Removing ambiguous characters such as O/0 and I/1 can improve manual entry. A smaller alphabet also shrinks the security space: six characters from an alphabet of 32 symbols provide exactly 30 bits.

If product requirements can change, increasing the length is the most direct way to improve brute-force resistance. With Base36, 13 characters provide approximately 67.2 bits. Usability, value, and the threat model must be considered together.

Choosing among the designs

FF1 is not always the answer.

ConditionsReasonable starting point
Low issuance volume with database-backed state validationSecureRandom + UNIQUE
An internal identifier not exposed to untrusted usersSequence + Base36
Scrambling order outside a security boundaryAffine modular permutation
Fixed six-character format, high-volume issuance, and issuance-order hidingFF1 + database issuance-state validation
High-value benefit or many simultaneously active codesLonger codes + target binding + multidimensional rate limits

The practical benefit of FF1 is that it preserves collision-free sequence inputs while hiding issuance order. If the system already needs database rows and redemption state, and namespace occupancy is low, SecureRandom + UNIQUE may be simpler and entirely sufficient. The design should first establish a real requirement for the complexity of a cryptographic construction and key rotation.

Conclusion

The central task in designing a six-character redeem code is not choosing one algorithm. It is separating the requirements.

  • SecureRandom produces hard-to-predict candidates but does not make duplicates impossible.
  • A sequence removes duplicates but does not hide issuance order.
  • A modular permutation creates a perfect one-to-one mapping but retains linear structure.
  • FF1 provides a keyed permutation but does not authenticate a code.
  • Database state determines whether a code was issued, has expired, or remains usable.
  • Length, target binding, and rate limiting defend the 31-bit space against online guessing.

A large multiplier does not create security, and a long AES key does not enlarge a six-character output space. Even a reviewed cryptographic construction cannot replace validity state and operational policy.

In one sentence:

Use mathematics and the database for collision-free issuance, a reviewed keyed permutation to hide order, issuance state for validity, and code length plus operational controls for brute-force resistance.

References

  1. NIST SP 800-38G — Methods for Format-Preserving Encryption
  2. NIST SP 800-38G Rev.1 — Second Public Draft
  3. Bouncy Castle Java FPE API
  4. Bouncy Castle Java releases
  5. OWASP ASVS 5.0 — Code-based mechanism brute-force protection

This article is technical material for design and learning. Real payment, asset, and authentication systems require a separate security review covering the threat model, regulation, disaster recovery, key management, and incident response.