Encoding troubleshooting · Updated October 20, 2026

Crockford Base32 Check Symbol Mismatch: Stop Stripping the Final =

When your field uses Crockford Base32 with its optional check symbol, a final = can be real validation data, not padding. The integer 35 has data symbols 13 and checked representation 13=. Removing the equals sign makes our checked-value validator report check symbol mismatch. Confirm the field's format before applying RFC 4648 padding cleanup.

The failing path: a cleanup step changes the field

A receiving service expects a checked Crockford identifier. A generic input cleaner removes trailing equals signs because they look like Base32 padding. The validator then receives a shorter string. This is a validation-contract failure: the data symbols and the final check symbol must be handled as distinct parts.

received = "13="
cleaned = received.rstrip("=")
print(cleaned)  # 13
# A checked-value decoder now treats 1 as data and 3 as the check.
# The expected check for 1 is 1, so this fails.

The same text 13 is a valid unchecked representation of 35. That does not make it a valid checked representation of 35. A decoder that guesses checked or unchecked mode from the final character can silently change the application contract. Keep the mode in the field schema or API settings instead.

35 becomes data 13 and checked value 13 equals. Removing equals changes how the validator splits the field.
Preserve the exact checked identifier until verification is complete.

A matrix generated from fixed integers

We ran a small integer encoder and modulo-check implementation to produce these rows. Every checked value was passed back through our validator. The input is an integer, not an arbitrary byte string: no byte width, UTF-8 text, or RFC 4648 bit-padding behavior is implied by this table. These are original reproducible fixtures, not outputs attributed to an external library.

IntegerData symbolsCheck symbolChecked field
00000
11111
31ZZZZ
3210*10*
3311~11~
3412$12$
3513=13=
3614U14U
37150150
12345C1SRC1SR

The neighboring values make the failure concrete. For 34 the extra symbol is $; for 35 it is =; for 36 it is U. These final characters are not interchangeable decorations. Only the equals-sign example is damaged by this particular cleanup function, which explains why a bug can pass several ordinary fixtures before failing on another identifier.

Re-run the failure and the repair

This standalone Python example implements the specific input policy used in our experiment. Its exception text is our own validator's message; we are not claiming that all Crockford packages emit this wording. The check position accepts the canonical uppercase symbols shown in the table. Data-symbol aliases and hyphens are handled explicitly.

DATA = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
CHECK = DATA + "*~$=U"

def decode_data(text):
    text = text.replace("-", "").upper()
    text = text.translate(str.maketrans({"O": "0", "I": "1", "L": "1"}))
    if not text:
        raise ValueError("missing data")
    number = 0
    for symbol in text:
        number = number * 32 + DATA.index(symbol)
    return number

def verify_checked(text):
    text = text.replace("-", "").upper()
    if len(text) < 2:
        raise ValueError("missing checked value")
    number = decode_data(text[:-1])
    if text[-1] != CHECK[number % 37]:
        raise ValueError("check symbol mismatch")
    return number

assert verify_checked("13=") == 35
try:
    verify_checked("13=".rstrip("="))
except ValueError as error:
    print(error)  # check symbol mismatch

The repair is to pass the unmodified checked string to verify_checked. After validation, use the resulting integer. Do not remove a character to get around a check failure. If the input is intentionally unchecked, use a separately declared unchecked path such as decode_data; do not downgrade to it automatically after verification fails.

What our tests cover

We checked integer encode/decode round trips and checked-value verification for every number from 0 through 9,999. The fixed matrix above adds readable boundary cases, including the five extended check symbols. We also tested the documented data aliases with o-1 and 0L, both interpreted as 1 by this implementation. Those checks establish our reference behavior; they are not evidence that a particular third-party SDK normalizes input the same way.

A check symbol detects some entry errors. It is not a signature or an authorization mechanism. For security-sensitive identifiers, verification of this symbol cannot replace authentication, access checks, or a cryptographic integrity mechanism.

Keep the URL layer separate

If this field travels in a query parameter, serialize the complete checked value as one value. For our fixed example, new URLSearchParams({id: "13="}).toString() produces id=13%3D. Parsing that query returns 13=; validation then proceeds on the restored field. URL escaping preserves the equals sign rather than deleting it.

Use the URL encoder and decoder to inspect that transport boundary. If you are debugging ordinary RFC 4648 block lengths, read the Base32 padding guide. If Python rejects an extended-hex alphabet or returns the wrong bytes, see the separate Base32hex decoder mismatch guide. Neither issue is fixed by treating a Crockford check symbol as padding.

Before changing your decoder

  1. Confirm whether the field declares Crockford encoding and whether its optional check is enabled.
  2. Keep one non-secret failing input unchanged, including its final symbol.
  3. Log the value before and after normalization to locate destructive cleanup.
  4. Verify the checked field before using its integer value.
  5. Test both the 13= fixture and an ordinary all-alphanumeric fixture.

Format reference: Douglas Crockford's Base32 specification. Check use is an application choice. The failure path, fixture table, Python implementation, and tests on this page were produced for this experiment.