Rethinking Redemption Codes, Starting with I and L
A question about confusing letters in redemption codes sent me back to the credit redemption feature in my AI drawing app. These are my notes on alphabets, input aliases, error detection, random generation, rate limits, and making sure credits arrive only once.
Recently, I came across a question on Zhihu, a Chinese Q&A site: “Why do so many redemption codes still use I and L?”
One answer was blunt. The gist was that developers were being lazy: Base24 already removes confusing letters and digits, so why make users stare at a code and work out which is which?
That immediately reminded me of my own AI drawing app. It has a feature for exchanging redemption codes for credits, and I had asked Codex to implement it. I hadn’t given code design much thought. Generate a random string, store it in the database, look it up when someone enters it, and add credits if it hasn’t been used. That sounds like enough to get the feature working.
I asked Doubao about it, then discussed it with Codex. When I had Codex inspect the existing implementation, it already excluded some confusing characters and used secure random generation, hashed storage, and a redemption transaction. But it had no dedicated check symbols.
There was more to this small feature than I had expected.
Laziness might explain some cases, but it can’t explain every redemption code. A system may need to preserve an old format, work within a length limit, or make a different tradeoff. A string alone doesn’t tell us why its developers chose it. Still, when building a new feature, there is little reason to leave all this work to the user.

Don’t Make People Guess the Letters
Mix ordinary letters and digits, and the obvious trouble starts with I, l, and 1, or O and 0. An unfortunate font or a heavily compressed image can make them hard to distinguish.
If people will type the code by hand, generation should account for that. Waiting until they make a mistake and then saying “code not found” is already a step too late.
Base24 Bravo Niner uses this alphabet:
BCDFGHJKLMNPQRSTVWXZ6789
It keeps 24 characters, leaving out the vowels, including Y, and the digits 0 through 5.
Interestingly, it still includes L. But I and the digit 1 are gone, so users no longer need to distinguish that particular group. Avoiding ambiguity doesn’t always mean banning a particular letter. It can mean keeping similar-looking characters from appearing together.
Doubao also mentioned Crockford Base32, whose alphabet is:
0123456789ABCDEFGHJKMNPQRSTVWXYZ
It keeps 0 and 1 but excludes I, L, O, and U. Generated output uses uppercase letters. Input is case-insensitive, with O accepted as an alias for 0, and I or L accepted as aliases for 1.
I like this approach better for my use case.
A user can mistake zero for the letter O and still enter the right value. The system doesn’t first demand that they learn to tell the characters apart. It simply treats those spellings as equivalent.
My requirement was to reduce confusion, not to ban zero from the output. If O never appears in a generated code and is accepted as an input alias for zero, keeping zero is fine.
Removing more characters also has a cost. At the same length, a smaller alphabet provides a smaller random space. Keeping the same random space means making the code longer. There are tradeoffs here; removing the most characters isn’t automatically the best choice.
And the alphabet doesn’t finish the job. Groups of four, clearly distinguishable letterforms, a copy button, and support for pasting the whole code all help more than asking people to transcribe one long string. There’s no need to split the input into seven or eight tiny boxes, either. Being able to move the cursor normally when fixing a character in the middle is useful enough.
Removing I Doesn’t Remove Typing Errors
Base24 still includes B and D, as well as M and N. So does Crockford.
On a clear screen, these may be easy to read. In a photo taken at an angle, or when someone only glances at the code, mistakes remain possible. People can also omit a character, repeat one, or reverse two adjacent characters.
An alphabet reduces mistakes. It can’t guarantee that people won’t make them. Check symbols can help detect the errors that remain.
Think of the check section as verification information that travels with the string. During generation, the system computes a few check characters from the data and includes them in the code. During entry, it calculates them again. A mismatch tells us something went wrong.
The check information must accompany the code for the frontend to validate it without a database lookup. The server must validate it again; validation in the page isn’t a reason to skip validation at the endpoint.
A regular expression can’t do the same job. It can check the length and reject characters outside the alphabet. Replacing B with D changes neither, so the regex sees nothing wrong.
Doubao recommended Crockford Base32 plus CRC16. CRC is an established error-detection technique, and that approach can work. But “CRC16” isn’t a complete specification. The polynomial and data length affect which errors are guaranteed to be detected. Initial values and bit ordering must also be agreed on, or the two implementations may produce different results. This CRC selection study examines how the polynomial and message length affect error detection.
I wanted a guarantee that maps directly to what someone might do: will a single wrong character be detected? What about swapping two? I’d rather have that answer than only a claim that the chance of an undetected error is small.
For this design, I’m planning to use Reed–Solomon checks at the character level, usually abbreviated to RS.
The name sounds rather large for a redemption-code feature, but the use here is small. Each Crockford character represents a value from 0 to 31, and the encoder computes four check characters.
With the fixed length and parameters in this design, any two valid codes differ in at least five character positions. This is the minimum distance. Reed–Solomon codes have distance n − k + 1, so four check symbols give a distance of 5. These coding theory lecture notes explain the property and its proof.
Changing one to four characters therefore cannot turn a valid code into another valid code: validation must fail. Swapping two different adjacent characters changes two positions, so that is detected too. A single omitted or extra character is rejected by the length check first.
This guarantee applies to character substitutions after normalization. A mixture of insertions and deletions that happens to preserve the total length, or an error affecting more characters, doesn’t automatically fall under the same guarantee.
RS can also correct errors, but I only plan to use it for detection here. If validation fails, the user should check the original code. The system shouldn’t guess a “probably correct” redemption credential on their behalf.
Input Tolerance Needs Limits
If O can be an alias for zero, could B and D, or M and N, be treated as interchangeable too?
That would create a different problem.
Crockford never generates O, I, or L. They are input aliases with unambiguous destinations: zero or one. B, D, M, and N all appear in generated output and represent distinct values. Merging them would also merge strings that were supposed to be different.
I plan to normalize only the things with a defined meaning: letter case, the agreed aliases, grouping hyphens, allowed whitespace, and full-width letters or digits to their ASCII equivalents. The frontend and backend need the same rules.
Other characters should produce a clear error instead of disappearing silently.
Suppose someone pastes an extra exclamation mark. If the application quietly removes it and later says redemption failed, the user has little idea what happened. Overlong input should likewise be rejected, rather than truncated and submitted.
The purpose of normalization is to accept equivalent spellings of the same value. Deciding which different value the user intended is another matter.
The Format I’m Planning to Use
For credit redemption in my AI drawing app, I’m planning this format:
ART2-0123-4567-89AB-CDEF-GHJK-XXAP
This is a locally verified teaching example. It has not been issued in any business system. The sequential characters make the structure easier to see; real generation must not use a sequence like this.
ART2 is the product and format-version prefix. The next five groups, 20 characters in total, are the securely generated random payload. The final group, XXAP, contains the four RS check characters. The prefix participates in the check too. Hyphens are only for display grouping.
Each random character contributes 5 bits of random space, giving 100 bits across 20 characters. The check section is derived from the preceding data. It adds no secret entropy and mustn’t be counted as part of the random space.
This length is a choice for my credit-redemption use case. The cost is straightforward: it’s longer than a short code without check symbols, which makes copy and paste support more important. It isn’t an industry-wide standard. It combines a public alphabet and an established error-detection algorithm with parameters chosen for one application.
Crockford’s original specification also includes an optional modulo 37 check symbol, with additional characters available for that symbol. This design borrows the alphabet and input-alias rules, then uses RS for its check section. That distinction matters. Saying “we use Crockford” doesn’t fully define this format.
The RS parameters must be fixed as well. This reference encoder uses GF(32), the primitive polynomial x⁵ + x² + 1, and primitive element 2. The generator roots are α¹ through α⁴, with polynomial coefficients ordered highest degree first, producing a shortened RS(28,24) code. Users don’t need to know any of this, but implementations must agree or they’ll calculate different check characters.
Codex and I have verified the standalone reference encoder and validator. The checks included exhaustive single- and double-character substitutions, adjacent swaps, input aliases, and cross-checking the check symbols using a separate calculation method. Three- and four-character substitutions were sampled for regression coverage. The full detection guarantee comes from the distance property; sampling isn’t a proof. These checks cover the codec, not a completed integration with the redemption endpoint.
There is also a limit that needs to be stated plainly. Both of these examples pass validation, and they differ in exactly five character positions:
ART2-0123-4567-89AB-CDEF-GHJK-XXAP
ART2-0123-4567-89AB-CDEF-GHJJ-3V37
If someone enters another complete valid code, the check cannot tell which code they originally meant. Adding check symbols doesn’t justify promising that someone can never redeem the wrong code. It guarantees detection within a defined range of errors.
Passing the Check Doesn’t Mean It Can Be Redeemed
So far, we’ve been dealing with input errors.
The check algorithm is public. An attacker can choose an arbitrary payload and calculate the correct check symbols. Passing validation only means the format is internally consistent. It doesn’t show that the system issued the code or that the code can still grant credits.
Resistance to guessing comes from secure randomness, a sufficiently large random space, and rate limits applied before redemption.
The payload shouldn’t be built from timestamps, sequential IDs, or an ordinary random function. A database can still use an auto-incrementing primary key; the credential handed to the user needs to be unpredictable. In Node.js, crypto.randomInt can select characters uniformly while avoiding modulo bias, as the official documentation explains.
Code length, the number of outstanding unredeemed codes, and the number of guesses an attacker can make all affect the risk. More check characters can’t replace those considerations.
Rate limiting deserves particular attention. Counting failures afterwards isn’t enough if every request first looks up the code and attempts redemption. A client over the limit can still keep trying. The block must happen before the business operation, alongside account limits, limits based on trusted client IP information, and cooldowns after failures.
For credentials of this kind, OWASP’s advice on one-time random tokens is useful: generate securely, provide sufficient length, store safely, allow one use, and limit attempts. The document covers password resets, but those random-token requirements are also worth borrowing for redemption codes.
After the database lookup, nonexistent, expired, disabled, or already redeemed by someone else can share a message such as “This code is invalid or no longer available.” Someone repeatedly trying codes doesn’t need a tour of their business states. Format and check errors can be explained more specifically, since that information is determined by the input itself.
Storing a SHA-256 digest of the complete normalized code, then calculating the same digest for lookups, also reduces the risk of keeping redeemable plaintext in the database. The payload still needs sufficient randomness. Hashing a short, enumerable code doesn’t make it safe.
Logs need attention too. If the database contains only hashes but request logs, error reports, or analytics contain complete codes, the plaintext has simply moved elsewhere.
Credits Should Arrive Only Once
Once the user enters the right code, there’s one more job: credit the account correctly.
A double click, two people redeeming the same code, a successful request whose response gets lost, or a client retry after a timeout are all ordinary events. They don’t require some enormous concurrency spike.
Marking the code as used, increasing the account balance, and writing the credit ledger entry should happen in one transaction. If any step fails, all of them roll back. A row lock or an atomic update conditional on the current state can ensure that the same code is consumed successfully only once.
The account balance needs protection too. Two different codes crediting one account simultaneously mustn’t let one update overwrite the other. Locking the redemption-code row doesn’t automatically protect every balance change.

This connects with my earlier article about locks and shared resources: identify what can be modified concurrently, then decide where to enforce the constraint.
There is also idempotency.
If the same user retries a successful redemption, the endpoint should return the original redemption result and the current balance without adding credits again. A different user mustn’t be allowed to claim the same code. The ledger entry for a redemption should also have a database uniqueness constraint.
A network timeout isn’t the same as a failed redemption. The response may be missing even though the transaction has committed. If retrying only returns “code already used,” the user may think the credits disappeared. If retrying adds the credits again, the developer gets a different kind of unpleasant surprise.
Batch generation has a similar retry problem. If an administrator doesn’t receive the first response, retrying shouldn’t create a second batch. Generation requests need an idempotency identifier. And if plaintext codes are shown only once, recovery or cancellation and reissuance after a lost response must be designed in advance.
When introducing a new format, previously issued codes still need to be recognized under their old rules. A version prefix can separate the formats, but a failed check on a new code mustn’t fall back to the old rules and get accepted anyway. Frontend length limits and input handling must change too, or the input field may truncate a code that the backend now supports.
Back to the Question About I and L
I started by wondering why redemption codes still contain I and L.
Removing confusing characters is fairly easy. What needs more thought is what happens afterwards: the remaining characters can still be misread, pasting can go wrong, and a user may retry because of a network problem after the credits have already arrived.
For my AI drawing app, the existing secure random generation, hashed storage, and transactions are useful foundations. The next work is to add explicit error-detection rules, consistent input handling on both sides, and rate limits that take effect before redemption, then verify the concurrency and retry cases.
The user just wants some credits. They shouldn’t need to study fonts, understand an encoding scheme, or guess whether their last redemption succeeded. Those are things to work out while building the feature.
Loading discussion...
Discussion failed to load. Reload