Skip to main content
All articles

How Safe Can a Six-Character Redeem Code Be?

· 13 min read
Bankware Global Engineering

Suppose we send a customer a six-character code such as 7Q2M9A for a benefit they may claim once. It must contain digits and uppercase letters, and another customer must not receive the same code. On entry, the server checks whether the code was actually issued, grants the benefit, and prevents a second use.

This is a bearer-style redeem code: possessing the string lets someone claim the benefit. It has different requirements from a public promotional code shared by many people. This article concerns individually issued codes.

Before choosing a cipher, we need to ask: which parts handle collision-free issuance, hiding issuance order, valid redemption, and guessing resistance?

The Default Choice and the Life of One Code​

An issuer with low namespace occupancy can usually start with security-grade randomness and a database UNIQUE constraint. A cryptographic permutation such as FF1 is an option when a fixed-length format and issuance-order hiding are both real requirements.

RequirementReasonable starting point
Low occupancy; database tracks issuance and useSecureRandom + UNIQUE
Unique identifier used only internallySequence + Base36
Fixed six characters, unique inputs, and issuance-order hidingFF1 + database state validation
High-value benefits or many simultaneously active codesLonger codes + target binding + attempt limits first

Following one code makes the responsibilities clear.

StageWhat happens to 7Q2M9A
GenerateDraw a secure random candidate, or transform a unique ID if using FF1.
Confirm issuanceStore the code, expiry, and usage state. Generate another candidate on conflict.
Receive a requestCheck for exactly six ASCII characters in the permitted alphabet.
Decide validityCheck issued/active/expiry/target conditions while changing the usage count atomically.
Deliver the benefitUse the same transaction or explicit duplicate protection; a retry must not grant it twice.

A well-formed string and a usable code are different things. The generation algorithm cannot replace the last two stages.

Start with Randomness and Database Uniqueness​

Java's SecureRandom can select one of 36 characters six times. nextInt(36) selects uniformly. Applying % 36 to an arbitrary byte can bias the result toward some characters.

// RNG is SecureRandom; ALPHABET is ASCII "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".
char[] code = new char[6];
for (int i = 0; i < code.length; i++) {
code[i] = ALPHABET[RNG.nextInt(ALPHABET.length)];
}
return new String(code);

This is the explanatory core. The complete Java example, including imports and its class, is runnable using the appendix instructions. Randomness does not forbid duplicates, so the database makes the final uniqueness decision.

A preliminary SELECT cannot prevent concurrent requests from both observing an unused value and trying to insert it. In PostgreSQL, use UNIQUE(code) with this insertion:

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

Colon-prefixed values are application-bound parameters. If no row is returned, retry a bounded number of times with a new candidate. Do not treat unrelated database errors as collisions or loop forever. The appendix schema distinguishes RANDOM from FF1: RANDOM requires key_version=NULL; FF1 requires a positive key_version. This insertion matches it.

A Collision in the Past Does Not Make the Next Issuance Expensive​

Let NN be the number of six-character Base36 candidates. Ten digits plus 26 uppercase letters give:

N=366=2,176,782,336log⁡2N≈31.02 bits\begin{aligned} N&=36^6=2{,}176{,}782{,}336\\ \log_2N&\approx31.02\text{ bits} \end{aligned}

The approximate probability of at least one collision while drawing 100,000 candidates is 89.94%. Yet with 100,000 codes reserved in the database, the probability that the next candidate collides is only about 0.00459%. A collision just requires a new draw, so average retry cost is small at low occupancy. The full formulas and table are in the appendix.

Reserved codes are not the same as active ones. Expired or consumed rows still occupy the namespace while retained under UNIQUE. Deleting an old code and reissuing it can give a new recipient a string the old recipient still knows; expiration alone does not make reuse safe.

Make Redemption One Atomic Decision​

If a one-time code is read and its state updated separately, two concurrent requests can both observe it as usable. Combine status, expiry, and usage checks in a conditional update.

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;

Only a returned row means the usage-state transition succeeded. With max_uses=1, the example becomes CONSUMED after the first request, so the second cannot succeed. If a code is bound to an account or order, include that condition in the same WHERE clause.

Deliver the benefit in the same transaction when possible. An external delivery system needs a boundary such as an idempotency key, which lets it recognize the same request, and an outbox, which records pending delivery work together with the state change. Account for cases where consumption succeeds but external delivery fails.

Overly distinct responses for missing, expired, and consumed codes can help an attacker discover valid strings. Keeping plaintext codes out of application and analytics logs is part of this workflow, too.

Two Billion Candidates Still Permit Guessing​

The number of reserved codes relevant to issuance collisions differs from the active codes an attacker can redeem. Let mm be the active set reachable through the attack path. One uniformly chosen candidate succeeds with probability m/Nm/N.

Attack targetSuccess per uniform guessExpected attempts without repeats
One particular active code1/N1/N(N+1)/2(N+1)/2, about 1.088 billion
Any of mm active codesm/Nm/N(N+1)/(m+1)(N+1)/(m+1)
Any of one million active codesAbout 0.0459%About 2,177

This assumes a fixed active set and guesses without repetition. With account binding, mm is the set claimable through that request, not the whole system's code count. Allowed attempt rates and expiration are separate operational policies.

The space is small for protecting a high-value benefit with the six-character code alone. Even a 256-bit AES key leaves only about 31 bits of external candidates. Search space grows exponentially with bit count; a long key cannot replace the guessing resistance missing from a short output.

If the format must remain six characters, combine short expiry, one-time use, and account/order binding. Limit attempts by account, device, session, and campaign as well as IP, and define progressive delays, blocking, and detection. The rate limiting and consideration of 64 bits in OWASP ASVS 5.0 V6.6.3 address out-of-band authentication. They provide a comparison here, not a blanket requirement for all redeem codes.

Removing ambiguous characters such as O/0 and I/1 can improve entry but shrinks the space: six characters from a 32-symbol alphabet provide exactly 30 bits. If length can change, 13 Base36 characters provide about 67.2 bits. Choose length with both usability and benefit value in view.

When Issuance Order Must Also Be Hidden​

If randomness plus the database meets the requirements, we can stop there. When generation must preserve unique inputs and hide issuance order, compare ways to turn a unique ID into a six-character code.

TransformationWhat it suppliesWhat remains
Sequence → Base36Distinct codes for in-domain IDsReveals order, such as 00000Z followed by 000010
Affine modular permutationA one-to-one shuffle of IDsConsecutive differences retain linear structure
AES-based FF1A keyed permutation under one key and tweak in a fixed-length domainNo authentication tag; still needs state validation and guessing defenses

A sequence may have gaps. Rollbacks, conflicts, and failures can consume numbers, which count toward capacity. Reject IDs outside 0≤id<N0\le id<N; reducing them modulo the domain repeats old codes. Large multipliers or primes do not make an affine transformation secure. The proof and executable helper are in the appendix.

FF1 is format-preserving encryption (FPE): it retains the alphabet size and length. Truncating or reducing AES output to six characters can collapse different values into the same short output, whereas FF1 maps the chosen domain one-to-one. This example uses radix 36, length six, versioned AES keys, and a fixed namespace tweak.

NIST SP 800-38G specifies FF1. As checked on 2026-09-07, Revision 1's second public draft is not a final publication and requires a minimum domain of one million. Here 36636^6 exceeds that value, but meeting a domain minimum does not establish online redemption security.

FF1 Decryption Does Not Confirm Issuance​

Every well-formed six-character Base36 string decrypts to some ID. FF1 is not an authentication tag or signature. Returning a value from decode() is not a reason to grant a benefit. Actual issuance, status, expiry, and target conditions must be checked in the database. This article's default redemption path looks up the code directly and does not need FF1 decryption at all.

A tweak need not be secret, but it must be fixed to preserve one permutation's uniqueness across a code space. Outputs under different keys or tweaks can overlap, so retain global UNIQUE(code). On conflict, reserve a new ID rather than encrypting the same ID again. Truly disjoint code namespaces for different versions are another option.

Different keys may even map the same individual input to the same output by chance. Do not test the universal claim that changing a key must change every output. This does not contradict one-to-one behavior under a single key and fixed tweak.

With direct code lookup, old FF1 keys are not necessarily required merely to redeem old codes. Retention depends on regeneration, decryption, and audit requirements. A decryption-based path must first define how the code or separate request context identifies the key version to use.

That division of responsibilities guides the choice: use mathematics and the database for generation uniqueness, a reviewed permutation when order hiding is needed, issuance state for validity, and length, binding, and attempt limits for guessing resistance.

Appendix A: Executable Examples and Actual Verification Scope​

Complete Java code, ID reservation, and database schema

The files are separated into the FF1 codec, generation helpers and checks, JDBC issuance flow, schema, and SQL checks. The public fixed key in the checks is only a fixture.

The shared schema distinguishes RANDOM and FF1 with method. RANDOM rows have NULL key_version; FF1 rows require a positive version. The database also checks VARCHAR(6), ASCII format, usage status and counts, and global uniqueness. A separately keyed HMAC could replace plaintext codes as the lookup key. Keep the HMAC and FF1 keys separate; an unkeyed hash cannot stop offline enumeration of a 31-bit domain.

FF1 needs an ID before encryption, so reserve one from an explicit sequence rather than waiting for an identity insert.

-- 1. Atomically reserve an ID; rollback does not restore the number.
SELECT nextval('redeem_id_seq') AS id;

-- 2. In the application: code = codec.encode(id).
-- 3. Store the same id and its key_version.
INSERT INTO redeem_code (id, code, method, key_version, expires_at)
VALUES (:id, :code, 'FF1', :key_version, :expires_at)
ON CONFLICT (code) DO NOTHING
RETURNING id;

If no row is returned, restart from step one with a new ID; stop after 32 attempts. The JDBC example connects these three steps. The caller owns commit/rollback and sends the code to the customer only after commit succeeds. The example retains expired rows, so its reserved count is not its active count.

The FF1 engine takes radix digits from 0 to 35, not ASCII bytes. Java's Character.digit accepts some non-ASCII inputs, so the example checks ASCII ranges explicitly. Supporting lowercase requires a consistent normalization policy at every entry point. A new engine is created per call; optimizing reuse requires checking thread safety and measuring it.

This lightweight API uses raw AES key bytes in the process. A non-exportable KMS/HSM key does not plug into it automatically. Use a provider with direct FF1 support or define an explicit boundary for handling data keys in memory.

From the repository root, reproduce the checks in a temporary directory. The dependency is downloaded from Maven Central.

REDEEM_CHECK_DIR=$(mktemp -d)
REDEEM_EXAMPLES=web/apps/loonshots-blog/static/examples/six-character-redeem-code
curl -fL -o "$REDEEM_CHECK_DIR/bcprov.jar" \
https://repo.maven.apache.org/maven2/org/bouncycastle/bcprov-jdk18on/1.85.2/bcprov-jdk18on-1.85.2.jar
javac -cp "$REDEEM_CHECK_DIR/bcprov.jar" -d "$REDEEM_CHECK_DIR" \
"$REDEEM_EXAMPLES/RedeemCodeFf1.java" \
"$REDEEM_EXAMPLES/VerifyCodes.java" "$REDEEM_EXAMPLES/IssueCode.java"
java -cp "$REDEEM_CHECK_DIR:$REDEEM_CHECK_DIR/bcprov.jar" VerifyCodes
cp "$REDEEM_EXAMPLES/schema.sql" "$REDEEM_EXAMPLES/verify-sql.mjs" "$REDEEM_CHECK_DIR/"
npm install --prefix "$REDEEM_CHECK_DIR" --ignore-scripts --no-audit --no-fund @electric-sql/pglite@0.5.8
node "$REDEEM_CHECK_DIR/verify-sql.mjs"

On 2026-09-07, the examples compiled with JDK 21.0.7 and Bouncy Castle 1.85.2. Checks covered official NIST FF1 sample #1, boundary values, invalid ASCII, determinism, and 100,000 unique round trips. This is neither the full official vector suite nor a production-security assessment.

SQL was checked in PGlite 0.5.8's PostgreSQL 18.3/WASM engine for schema creation, random insertions and duplicates, ID reservation and cross-version conflicts, repeated-use rejection, and expiration. Its FF1 codes are fixed strings for testing the storage path. Integration between Java/JDBC and a PostgreSQL server, multi-session concurrent redemption, external benefit delivery, key management, and performance were not tested. Those boundaries need separate validation before production use.

Appendix B: Collision Probabilities and the Permutation Condition​

Probability denominators, the square root, and the affine proof

kk is the number of random candidates drawn, nn the codes still reserved by the database, and mm the active codes reachable through an attack path. They answer different questions. For independent uniform draws, cumulative collision probability is approximately:

Pcollision≈1−exp⁡ ⁣(−k(k−1)2N)P_{\mathrm{collision}}\approx1-\exp\!\left(-\frac{k(k-1)}{2N}\right)
Cumulative candidates kkApproximate probability of at least one collision
10,0002.27%
50,00043.69%
55,00050.08%
100,00089.94%

Approximating k(k−1)k(k-1) as k2k^2 gives this square-root expression for the 50% point:

k50≈2Nln⁡2≈54,933k_{50}\approx\sqrt{2N\ln 2}\approx54{,}933

But the next candidate's collision probability and expected attempts until success use the reserved count nn:

Pnext=nNE[attempts]=11−n/N\begin{aligned} P_{\mathrm{next}}&=\frac{n}{N}\\ \mathbb{E}[\mathrm{attempts}]&=\frac{1}{1-n/N} \end{aligned}

Issuance becomes impossible when the space is full. Monitor occupancy and sequence exhaustion rather than retrying forever, and expand the length or use genuinely separate namespaces before exhaustion.

The affine transformation is f(x)=(Ax+B) mod Nf(x)=(Ax+B)\bmod N. If gcd⁡(A,N)=1\gcd(A,N)=1, an inverse exists, so f(x)=f(y)f(x)=f(y) implies x≡y(modN)x\equiv y\pmod N, and hence x=yx=y inside the domain. Since 366=21231236^6=2^{12}3^{12}, AA must be divisible by neither two nor three. However, the linear relation f(x+1)−f(x)≡A(modN)f(x+1)-f(x)\equiv A\pmod N remains. Large or secret constants do not remove it, so this is not a security boundary for an external bearer credential.

The affineCode function in VerifyCodes.java calls the base36 helper in the same file. If the constants change, recheck Java long multiplication bounds.