Time-sortable, prefixed unique identifiers using Crockford Base32. Pure-CFML port of agency.wilde:ashid. IDs produced by this library are byte-for-byte interchangeable with the upstream v1.0.3 Kotlin and TypeScript implementations (see Upstream version notes below - note that the published Maven JAR is older v1.0.0 and produces different output for prefixed IDs).
ashid("user") // user_1kbg1jmtt4v3x8k9p2m1np0
ashid() // 01kbg1jmtt4v3x8k9p2m1n0w
ashid4("token") // token_<13 random><13 random>
UUIDs are opaque. ashid IDs are self-documenting (user_..., evt_...), lexicographically time-sortable, double-click-selectable, and case-insensitive. Inspired by Stripe's ID format, Crockford's Base32, ULID, and TypeID.
CommandBox / ForgeBox:
box install ashidOr copy Ashid.cfc, EncoderBase32Crockford.cfc, and helpers.cfm into your project and reference them via a CFML mapping.
Try it without setup: Visit http://127.0.0.1:8123/demo.cfm after starting any CFML server in this directory. The demo lets you generate IDs, parse them, and verify every API method without installing anything else.
// Direct CFC use
var ashid = new ashid.Ashid();
ashid.generate("user"); // user_1kbg1jmtt4v3x8k9p2m1np0
ashid.generate4("token"); // token_<26-char base, two random components>
ashid.parse(id); // ["user_", "1kbg1jmtt", "4v3x8k9p2m1np0"]
ashid.parse(id, "struct"); // { prefix, timestamp, random }
ashid.timestamp(id); // 1778025600000 (ms since epoch)
ashid.isValid(id); // true / false
ashid.normalize(id); // canonical lowercase formTop-level UDF style (matches the upstream calling convention):
// In Application.cfc onApplicationStart:
application.ashid = new ashid.Ashid();
include "/ashid/helpers.cfm";
// Then anywhere:
var id = ashid("user");
var parts = parseAshid(id); // [prefix, ts, random]The helpers.cfm resolver looks for the singleton in request -> application -> server scope and throws ashid.NotWired if none is set.
| Method | Returns | Description |
|---|---|---|
generate([prefix]) |
string | Time-sortable ID with optional prefix |
generate4([prefix]) |
string | Two-random ID (no timestamp), 26-char base - UUID-v4 equivalent |
create(prefix, time, randomLong) |
string | Deterministic factory (testing, replication) |
create4(prefix, random1, random2) |
string | Deterministic ashid4 factory |
parse(id, [returnType]) |
array or struct | ["user_", ts, rnd] or {prefix, timestamp, random} |
prefix(id) |
string | Prefix incl. trailing _ (empty for unprefixed IDs) |
timestamp(id) |
numeric | Recovered ms-since-epoch |
random(id) |
java.lang.Long | Random component (standard ID; signed 63-bit) |
randomULong(id) |
java.math.BigInteger | Random component preserving full 64-bit (for ashid4) |
isValid(id) |
boolean | Format check |
normalize(id) |
string | Canonical form via parse -> decode -> re-encode |
Top-level UDFs in helpers.cfm: ashid([prefix]), ashid4([prefix]), parseAshid(id).
With prefix (variable length): <normalizedPrefix><optionalTimestamp><random>
- Prefix is auto-stripped of non-alphanumeric chars, lowercased, with
_auto-appended. Soashid("user"),ashid("user_"),ashid("user-"),ashid("USER"), andashid("u-s-e-r")all produce the sameuser_...prefix. - When
time == 0, the timestamp portion is omitted entirely and the random is unpadded. Otherwise the timestamp is unpadded Crockford Base32 and the random is padded to 13 chars.
Without prefix (fixed 22 chars): <9-char zero-padded timestamp><13-char zero-padded random>
ashid4 form (fixed 26-char base, no timestamp): <13-char random1><13-char random2> - UUID-v4-equivalent. Use when unpredictability matters more than time-sortability.
Alphabet: 0123456789abcdefghjkmnpqrstvwxyz (lowercase, 32 chars). Decode tolerates uppercase and Crockford lookalikes (I, L -> 1; O -> 0; U -> V).
Prefixes must be letters only at parse time. The upstream parse() walker uses isLetter(), which rejects digits. So ashid("u1") will produce an ID, but the resulting string can't be parsed back (the walker bails on the digit). isValid() returns false. Use letter-only prefixes ("user", "event", "token", etc.) - this matches upstream Kotlin behavior. We have not patched this in the CFML port to preserve byte-for-byte parity.
normalize() works on ashid4 IDs in this port (upstream divergence). Upstream's normalize() round-trips through create(), which validates the first encoded slot as a timestamp. For ashid4 form, that slot holds random1, and roughly half of all random1 values exceed Long.MAX_VALUE once decoded - so upstream throws "Ashid timestamp must be non-negative" on those inputs. This CFML port detects ashid4 form (first encoded slot is exactly 13 chars) and routes to create4() instead, so normalize() works on every ID that parse() and isValid() accept. If you need exact upstream behavior here, catch ashid.InvalidArgument from normalize() yourself and treat ashid4 inputs as already-canonical.
| Engine | Status |
|---|---|
| Adobe ColdFusion 2016+ | Tested (2016 + 2025) - see tests/specs/ |
| Lucee 5+ | Tested |
| BoxLang 1+ | Tested |
The library uses only Java standard library classes (java.security.SecureRandom, java.math.BigInteger) - no third-party JARs required at runtime.
No third-party CFML test framework dependency - tests use a tiny in-tree runner at tests/run.cfm. No box install needed before running tests.
The library is thread-safe when used as a singleton (typically wired in Application.cfc onApplicationStart). The internal java.security.SecureRandom instance is documented thread-safe by the JVM, and the encoder holds no mutable state.
IDs produced by this CFML port are byte-for-byte interchangeable with the upstream v1.0.3 Kotlin and TypeScript implementations. Verified by tests/specs/KnownVectorsTest.cfc (13 frozen vectors, 5 hand-derived from the algorithm + 8 self-locked CFML output).
- Source of truth: github.com/wildeagency/ashid
mainbranch (v1.0.3 per the repo's CHANGELOG.md). The cached upstream source we ported from is atAshId.ktandEncoderBase32Crockford.kton the upstream repo. - Maven Central currently ships
agency.wilde:ashid:1.0.0only. v1.0.0 predates theashid4API and the auto-underscore prefix normalization. Our CFML port and the published Maven JAR will NOT produce identical IDs for prefixed inputs: the JAR'screate("user", ...)producesuser...(no underscore), while ours producesuser_.... If you need identical-bytes parity with the published Maven JAR specifically, build a v1.0.3 JAR from upstream source yourself. - The JAR in
lib/ashid-1.0.0.jaris kept for benchmarking only, not as a runtime parity oracle.
CFML vs. v1.0.0 JAR (benchmark/run.cfm, 50,000 iterations):
| Engine | Op | CFML ms | JAR ms | Ratio | CFML ops/sec |
|---|---|---|---|---|---|
| Lucee 5 | generate("user") |
4971 | 213 | 23.34x | ~10,058 |
| Lucee 5 | generate() |
4513 | 67 | 67.36x | ~11,079 |
| Lucee 5 | parse(id) |
275 | n/a | n/a | ~181,818 |
| Lucee 5 | generate4("tok") |
6504 | n/a | n/a | ~7,687 |
| ACF 2016 | generate("user") |
7546 | n/a* | n/a | ~6,626 |
| ACF 2016 | generate() |
7238 | n/a* | n/a | ~6,907 |
| ACF 2025 | generate("user") |
5000 | 173 | 28.90x | ~10,000 |
| ACF 2025 | generate() |
4413 | 65 | 67.89x | ~11,330 |
| ACF 2025 | parse(id) |
230 | n/a | n/a | ~217,391 |
| ACF 2025 | generate4("tok") |
5952 | n/a | n/a | ~8,400 |
| BoxLang 1 | generate("user") |
9669 | 327 | 29.57x | ~5,171 |
| BoxLang 1 | generate() |
8525 | 149 | 57.21x | ~5,865 |
* ACF 2016 doesn't accept the 3-arg createObject("java", class, [jars]) form so the JAR isn't loaded for those rows; CFML-only timing. ACF 2025 does accept that form, so JAR comparison is available there.
CFML is 23-67x slower than the Java JAR - expected for BigInteger-heavy code without HotSpot inlining of CFML-internal calls. The chained BigInteger.add().multiply() operations now go through java.lang.reflect.Method.invoke (BoxLang 1.x routes a bare bigInteger.add(...) through its Number BIF when the receiver is numerically zero, which fails with "Required argument number is missing for function add"), costing ~25-30% throughput vs. direct member calls but making the same code work on every target engine. At 5,000-11,000 ops/sec for prefixed generation, that's still plenty for any typical CFML workload (you'd be allocating IDs at three orders of magnitude slower than this in any realistic request).
Run your own benchmark:
box server start cfengine=lucee@5 --port=8123 --background
# Visit: http://127.0.0.1:8123/benchmark/run.cfm?n=100000See benchmark/README.md for methodology and caveats.
No third-party CFML dependencies are required - tests use a tiny in-tree runner (tests/Assert.cfc + tests/run.cfm).
git clone https://github.com/jamoCA/cf-ashid
cd cf-ashid
box server start cfengine=lucee@5 --port=8123 --background
# Run tests (HTML view):
open http://127.0.0.1:8123/tests/run.cfm
# Run tests (plain text, for CI):
curl "http://127.0.0.1:8123/tests/run.cfm?format=text"
# Run benchmark:
curl "http://127.0.0.1:8123/benchmark/run.cfm?n=100000"- ashid for CFML: time-sortable, prefixed IDs without a JAR - announcement post on myCFML covering the why, the BigInteger gotcha, and cross-engine performance.
MIT, mirrored from upstream agency.wilde:ashid. See LICENSE.
- Crockford Base32 - Douglas Crockford's encoding spec (alphabet + lookalike-character mapping). Designed for human-readable identifiers.
- Original Kotlin/TypeScript implementation: Wilde Agency.
- CFML port: James Moberg.