Sqids – Generate short unique IDs from numbers
sqids.org
sqids.org
A secure "random-looking id generator" is called a block cipher. Block ciphers with less than 128 bits of output are widely considered insecure: this corresponds to about 22 base64 characters.
Going further, you probably do want to use a secure algorithm. History is full of people thinking "we don't need this to be secure, it's not used for security-sensitive things" and later regretting it. Some case studies at [1].
[1] https://www.schneier.com/blog/archives/2016/04/security_risk... .
But in the "Not good for" section it states the opposite: "[not good for] User IDs can be decoded, revealing user count" so it can be reversed?
>I appreciate that the author clearly states that security, (i.e., output can't be reversed back to the input), is a non-requirement.
Personally I think it's stupid but this is a tempting solution.
https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Dire...
When I first joined $company, HR sent me a SharePoint document with a numerical ID. Incrementing or decrementing the ID allowed me to view personal information of other employees including their pay.
My issue with Sqids is they're longer and more complicated than the input!
The standard choice of 4 to 8 random digits works well and it's clear what level of security they provide. Digits are easier to understand than case sensitive latin characters, especially when your native language uses a different character set.
The uniqueness from the system comes from the fact that two different numbers will never have the same id.
The pad only works for things like user ids.
You can also change the alphabet it uses so its not case sensitive; using this alphabet: "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" and a minimum of 8 digits, it will produce 8 digit ids all the way 4294967295 (0xffffffff).
They should use a similar dictionary approach IMO because I looked at the implementation and it’s hardcoded to look for “bad” words
Otherwise looks real straightforward! I’d love to see some performance test suites for it
[0]: https://github.com/sqids/sqids-javascript/blob/ebca95e114932...
[1]: though with UUID v4 so common to generate and well optimized in most languages I wonder if these userland solutions are really better. You can always generate a UUID and re-encode with base32 or base64 with also is well optimized in most languages
That doesn't seem possible. How would that work?
> I looked at the implementation and it’s hardcoded to look for “bad” words.
If you mean https://github.com/y-gagar1n/nanoid-good, that seems to be doing the same thing.
In general, I'm a bit weary of solutions that "guarantee no bad words" – this is usually highly language-specific: One language's perfectly acceptable name is another language's swear word.
We use it in a highly internationalized product spanning multiple languages and haven’t yet ran into a complaint or value on audit that would constitute something offense in any language per our intl content teams anyway.
That isn’t to say it’s 100% (and simply enough we don’t audit every single URL) but I suspect we would have gotten at least a user heads up by now
Never the less we are moving our approach to uuids that get base32 encoded for some of our use case for this. They’re easier to work for us in many scenarios
https://registry.npmjs.org/naughty-words/-/naughty-words-1.2...
From a quick look, the lists are pretty short, except for the one with English words that at least have some 404 words, but I can imagine there are far more bad words that you want to avoid than just those?
agree; b00b, DlCK, cntfcker
But I suppose, if user doesn't get to craft input, the collision space of converted numerical ids and words like above is sufficiently small to be ignorable.
(Sorry)
In the early dotcom era the company I worked for were about to go live and the final step was demoing the end to end flow to the ceo. I had done the back end stuff and hadn't paid much attention to the front-end. The person who did the account creation process wanted to nudge people to generate memorable yet strongish passwords, so when it created your account it would generate with a random password which he did by choosing 2 four letter words at random from the unix dictionary and putting a two digit number between them. He ran that past me as an idea and I thought "yeah, good idea" and didn't think more of it.
However he forgot to first grep out all the naughty words so when we demoed it to the CEO/non-technical founder both of the words in his randomly generated password were swearwords.
i.e if you sign up and get user id 32588 and make another account a few days later, you can tell the growth rate of the company.
And this is possible with every resource type in the application.
I do wonder how much the url bar junk thing matters these days. I tend to use uulids (waiting on uuid v7 wide adoption), and they're a bit ugly, but most browsers hide most of the urls now anyway. The fact that there is a builtin time component comes in clutch sometimes (e.g. object merging rules).
You can even do this when you don’t know the exact interval by using probabilities. The Allies used this method to estimate German tank production in World War II by analyzing the serial numbers of captured or destroyed tanks.
This is know as the German Tank Problem [1]
If you have integer IDs it is also trivial to find authorization flaws on your own. Any pentester will go for it right away.
If you make non guessable IDs they might skip it and go look for other stuff.
I’m a lawyer and using sequential IDs in a fraud case right now, to determine the number of victims.
Unfortunately, so far, I only have the IDs of two victims, and those are from just within about a month, whereas the fraud has likely been going on for several years. Just simply extrapolating that growth rate isn’t going to be very accurate.
Also, I suspect that the perpetrators did not start at ID 1.
that'll be like trying to determine the average salary in a company with only two known ones, which could be the janitor's and the CEO's
Ironically that would be somewhat close to the actual average.
Not chrome...
Also, links are a thing in chat, etc
> https://www.youtube.com/watch?v=fFMzQ3tYTFU&pp=ygURY2hQImVzZ...
Or Twitter:
> https://x.com/elonmusk/status/172853302828286055507?s=20
Or TikTok:
> https://www.tiktok.com/@<userId>/video/730292574259232054785...
While I tend to strip the tracking params and there are extensions that do this, I don't think most people do. These URLs are pretty 'ugly'.
So if the links that are being shared most on the internet (YT, TikTok, Twitter) don't care, you probably shouldn't either. I think the onus is on the UI layers (Chat apps, etc) to show urls how they look best on their respective platforms.
Edit: to this point, it looks like HN truncates these to make them less ugly too.
Don’t know how common that is. Wouldn’t be surprised if no -techies don’t know how to copy from url-bar.
Does anyone have an example they can reference of a business being harmed by this information being out there?
So I guess that kinda harmed that fraudulent business strategy…
Get it wrong and we jump from actionable business metadata to actionable business data (like perhaps which of your customer's customers are poachable)
What's the advantage of uuid7 (sequential + random) vs uuid4 (full random) for this?
There are probably some business cases where the “when” information is potentially useful (I cant think of any) but, you cant know, for example, how many users are in the database.
It can make sense for some situational internal database use case where you want temporal locality and can't use full sequential since it's distributed, and even then your DBMS might recommend something else, e.g. Spanner explicitly says not to do this. And it doesn't need to be exposed to users.
But I do agree, if performance isn't an issue with your db choice and you're not interested in in getting a free "created_at", might as well go fully random.
Instead of giving user an ID in response, user gets hmac(cipher(Data, secret_key), secret_key) + cipher(Data, secret_key) and then some simple pre-request handler just iterates over query params / form data and decrypts them if signature matches.
It also works as a really nice CSRF protection as user ID of currently signed user can be embedded into Data and checked if current user.id == decrypted data.id.
Another nice advantage is that you can deny the request right in the beginning as you know ahead of time that the provided data is not valid (signature doesn't match), saving some DB queries.
The down side is that URL gets pretty long though, but if that's hidden by browser or user doesn't care, it's a non-issue
Also, the language pills differentiate between those that have been implemented (color logo, dark text, bold) and those that aren’t (grayscale).
https://github.com/sqids/sqids-dotnet/issues/2#issuecomment-...
Which is fine, they don’t propose to filter “bad” words in other languages, but kind of funny when that’s one of the highlighted examples, right next to the goal of filtering words. Goes to show how hard it is to filter profanity generally for international audiences
The problem of "pick any N symbols that don't make any profanity in any language across all time" isn't what this is solving, nor should it have to. Take the same concept but use whitelisted words to build the token if you're that adverse to computer generated, fill in the blank naughty words. Keep "pen" and "island", among other things, off that list ;)
We're using randomly generated strings for many things. IDs, password recovery tokens, etc. We've generated millions of them in our system, for various use-cases. Hundreds of thousands of people see them every day.
I've never heard any complaints about a random content-id being "lR8vDick4r" (dick) or whatever.
But nowadays our society is so afraid of offending anyone, that profanity filters has extended all the way to database IDs and password recovery tokens.
(there are some legit cases, like randomly generated IDs for user profiles shared in public URLs, that users have to live with, but even there just make the min length 8 and you're unlikely to have any full-word profanity as the complete ID; put differently, I don't understand why they made the block list an opt-out thing)
https://github.com/compiler-explorer/compiler-explorer/issue...
Me and you will know it's just random characters, but if HR enters the chat...
First, it's highly incomplete because you can find at least 10x more combinations spelling the same "word". And probably 10x more slurs that aren't in this block list. Second, because it's hardcoded in your source. Third, because there are more elegant solutions.
Such as to pick an alphabet that can't spell readable words unless you're trying really hard to read a slur into it. Say this (no vowels or digits):
bcdfghjklmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ (length 42)
The full lower+upper+digits alphabet they use is 62. Feels like you're losing a lot, but... not really.
- A 128-bit id in base 62 = 22 letters.
- A 128-bit id in base 42 = 24 letters.
JUST TWO MORE LETTERS. And it's one more letter for 64-bit id (11 vs 12). And we can avoid this entire silliness. The problem is the author doesn't realize that logN is... logarithmic, I suppose.
bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ25679
Gives us base 45. And below is a JS snippet to make an id. There's your lib.
function id(num) {
num = BigInt(num);
const dict = "bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ25679";
let id = '';
while (num > 0n) {
id += dict[Number(num % 45n)];
num /= 45n;
}
return id || dict[0];
}
Example: id(123456789012345678901234567890n);
"bq99hC6fbtjLrkxLPm"Most gift card tokens for example don't allow the use of those two (or quietly correct it) to avoid making that mistake.
> 1234567890.to_s(36)
=> "kf12oi"
That gets us most of the way there, but Sqid has a Ruby library and lets you set a much higher base, including upper case characters, and I suppose, emoji. We're going to need much bigger numbers before that space savings makes much difference. I like it, but it's hard to know when something like that is worth adding a dependency.If I figure out you're using (36) then I know the next number 1234567891 is "kf12oj".
Not the case with Sqids.
user_1hrpt0xpax7ps
file_xpax7psaz0tv6az0tv6
Etc.
In distributed systems you can use the trailing bytes to encode things like author cluster, in case you're active-active and need to route subsequent writes before create event replication.
Easy to copy, debug, run ops/incall against. If you have an API, they're user-friendly.
Of course you still want to instruct people the prefixes are opaque.
I don't work retail, but something tells me people will make a stink out of just about anything if it meant potentially free products or other compensation.
Plus, are you filtering just English curse words or all curse words for countries that use Latin characters?
The mailing house that did the work was fired though.
They call it out on their front page.
Obfuscated
Which I guess is the part where senior (citizen) programmers like me get triggered as promised in the README.
Features like the profanity filter avoid creating URL routes like /user/cuntFh.
Cross language support allows interop between the encoder and decoder across microservices written in different languages.
Where do you have unique ids that aren’t the primary key? I would be more interested in a retrospectively unique truncated encoding for extant ulid/uuid; ie given that we’ve passed timestamp foo, we know that (where no external data is merged) we only need a bucketed time granularity of x for the random component of the id to remain unique (for when sortability is no longer needed).
Or just more generally a way to convert a ulid/uuidv7 to a shorter sequence if we are using it for external hash table lookups only and can do without the timestamp component.
For things that need public IDs, I always generate and store uuid4s* to avoid leaking database state or anything like that. Even uuid7 leaks creation timestamps. It's cheap and easy to map public IDs to/from PKs at the API boundaries.
* possibly encoded as something nicer like base64 for APIs or URLs
So instead of
/customers/1
/customers/2
You'll get something like /customers/4552956331295818987
/customers/3833777695217202560
Kinda similar idea to this library but you're encoding from an integer to another integer (i.e. it's format-preserving encryption). I like keeping the IDs as integers without having to reach for e.g. UUIDsIf the purpose of it is to give a friendlier url / id, who not use something like friendly_id instead? (http://norman.github.io/friendly_id).
The url is readable and searchable through the history.
I would much rather prefer people using "www.website.com/channel/video/a-dog-walking" instead of "www.website.com/channel/video/3cXv8c".
So I will probably keep Base64URL formatting my UUIDs to make them shorter for URLs, QR Codes and so on.
Quick example:
20b30b32-d421-4cfb-bdbc-9a4e0475abea
=>
MguzICHU-0y9vJpOBHWr6g
It's important to keep in mind that there are different ways to convert an UUID to a byte array (little/big endian, order of segments, ..)The reason for the limit is most likely to ensure interoperability with libraries in other languages where working with bignums is much more complicated.
The technique I used (I should publish it as open source) is using a Feistel cipher [2] with a key. The Feistel network could be adjusted to almost any size and the key used in every round is an expansion of a general key using a key derivation function [3] (KDF3 if I remember well).
Basically it is a symmetric cipher of arbitrary size.
[1] https://www.nektra.com/products/secure-coupon-code-generator...
I think there are 2 problems with this approach:
How do you prevent the double spend problem, for example duplicate entry tickets. You would have to mark the ticket as used in a central database anyway to prevent it
What happens if the secret key material is compromised? Anyone can issue new valid numbers, etc..
Please correct me if I'm wrong.
1/ You don't have duplicates because a Feistel network assures you there is no duplicates: every input has a different output with a fixed key in every round.
2/ If the secret is compromised anyone can issue valid numbers in the same way that if your secret encryption keys for encrypting your data exposes it. The idea is that the secret key material is never compromised as it is assumed in all security cases. You should custody with the right measures based on the attack vectors you have. The custody of secrets is independent of this method.
Please let me know if you have more questions.
That's not true, we have (perfect) forward secrecy, backwards secrecy and key rotation mechanisms because we often care what happened after the key is inevitably compromised. In this case the problem makes it hard to "rotate" the keys in a meaningful way, but I'm yet to see a proof it's impossible.
The assertion you mentioned is the "what" assumption while what you said is the "how" we came to that assertion. Most probably you take my word idea in a specific sense other than I wanted to use. My answer was informal because we are here in HN and not writing a paper.
For example, in the case of Philip Morris was about winning prizes and imagine if they tried other methods before where some smart people reversed the method.
[0] https://bytes.grubhub.com/why-we-use-crypto-when-generating-...
The ID is simply incremented if it is blacklisted [1]. So the ID is fixed to the blacklist content, and adjusting it in any way invalidates certain segments of previously generated IDs?
1. https://github.com/sqids/sqids-rust/blob/9f987886bc06875d782...
You're right that you can't update the blocklist in a backwards compatible way.
Not Good For:
[...]
User IDs
Can be decoded, revealing user count
So yeah, just using a sequential id and encoding the number with this library is not a viable idea if you want to hide your insights.And in general the algorithm is surprisingly complicated for something that could be replaced with simply base64 encoding, the given example (1,2,3) base64 encodes to a string with just one more letter than this algorithm.
That said I do appreciate the semicolon-free-style. I don't typically see that in libs besides my own.
https://github.com/sqids/sqids-javascript/blob/main/src/sqid...
> You have to account for scenarios where a new word might be introduced to the default blocklist
https://sqids.org/faq#future-blocklist
Honestly, I think they need to rethink this. Otherwise you've got different library versions for different languages each using different default blocklists, none of which are compatible.
They'll decode fine. Encoding might change.
Looks like it uses a hash. I was expecting it to just convert the base except with something to skip profanity, which would give you something much shorter going base 10 to base ~36. Tbh I don't see why it's like this.
But 127.0.0.1 looks more "readable" to me than lusab-babad
I don't think you could reduce the amount of random bits (I guess there are some non-random parts in standard UUIDs but not a significant amount) while preserving that property unless you add back some other form of synchronization to ensure that there aren't collisions which seems like it would defeat the purpose.
In theory it's definitely possible. The 128 bits you get in a UUID is a LOT of randomness for an identifier. Postgres BIGINTs are just 64 bits. Instagram's sharded IDs are just 64 bits. (See below.)
You can test it. If you're using uuidv4 (which is 100% random bits, minus a few for the version), you could make a new column in your table in Snowflake, populate it with the first 64 random bits of your existing uuid column, then see if you have any collisions.
https://instagram-engineering.com/sharding-ids-at-instagram-...
“$WEBSITE did 50,000 signups a month during the beginning of the pandemic, but now struggles to sign up a thousand a week” is a story.
For example, the number 5,702,400 becomes ~sorreg-namtyv. And 1,742,733,824 becomes ~master-morzod.
I enjoy the quirkiness of the names.
> User IDs - Can be decoded, revealing user count
Suppose you don't want to leak the count, what's a resonable way of implementing that?
You can of course have a uuid v7 / uulids or something as the primary key. Or have it as a public facing primary key, mapping back to a sequential ID PK (there might be some performance hits with larger PK's in e.g postgres? or is that just fud?)
But you could also generate a public ID with something like encrypt(seq_id, secret) and then encode it with whatever alphabet and or profanity filter you'd like - right? The issue then is that all public ID's would be long (and of course dealing with a decrypt operation on all incoming requests).
Don't know what's best really.
Yes, it's not that complicated (about 160 lines of low-density python), but it's portable, and portable, reusable code goes into libraries.
Given the retry-on-bad-word feature, I was sceptical of the no-collision claim -- but after looking at the JS source code, I'm confident it's correct.
For each number encoded, one character from the alphabet is "held back" to use as a delimiter (with the particular character chosen changing as each number is processed, presumably to make the output "look more random"). The very first character output is essentially a "free choice" that selects the initial permutation of the alphabet to use.
The algorithm is implemented as a function encodeNumbers() that calls toId() to encode each number, then checks for bad words and recurses with a new value of "increment" if it finds any. To prove correctness it's helpful to imagine an "in-between" function, tryEncodeNumbers(numbers, alphabet), which the outer encodeNumbers() calls in a loop to do most of its work (pseudocode):
function encodeNumbers(numbers) {
offset = sum(numbers)
do {
result = tryEncodeNumbers(numbers, permute(alphabet, offset + increment++))
} while (badWordIn(result))
return result
}
Here permute() is a function that permutes the characters in its first (string) argument according to its second (integer) argument in some arbitrary way.The toId(num, alphabet) function, which simply encodes a single number using "digits" taken from the characters in the alphabet parameter, is clearly injective with respect to the num parameter provided that alphabet contains no duplicate characters -- that is, if we hold some duplicate-free alphabet string fixed, every distinct value of num produces a distinct encoded string as output. (For example, toId(42, "0123456789") gives "42", and no other value of num produces this string when the alphabet remains unchanged.) tryEncodeNumbers(numbers, alphabet) first outputs a character representing alphabet (i.e., its initial alphabet -- which is its complete internal state), then joins together a bunch of these toId()-encoded numbers, with an extra character in between each that is known not to appear as a digit in the preceding number, permuting the alphabet in an alphabet-dependent but num-independent way for each encoded number output. Because the alphabet permutation is independent of the input array of numbers, this means that the alphabet used to encode the i-th number depends only on the initial alphabet and i. This means that, again holding its initial alphabet fixed, tryEncodeNumbers() is likewise injective with respect to the input array of numbers. (Suppose it were not: Then there is an initial dupe-free alphabet, and two distinct arrays of numbers, that produce the same output. Find the first position where the two arrays differ. Since the final results are identical by assumption, one of the two encoded outputs for this position must be a prefix of the other. But if the two encoded outputs are of different lengths, the shorter one must either terminate the entire string, making it shorter than the other encoded string, or be immediately followed by a character that cannot appear in the output of toId() with the alphabet used at this position, both of which contradict the assumption that the resulting strings are equal. Therefore toId() must have output identical strings for the two different numbers at this position, when given the same alphabet. But this contradicts injectivity of toId(), so this (non-injectivity of tryEncodeNumbers()) is impossible.)
The final step is to see that, if encoding two inputs with the top-level encodeNumbers() function gives the same first character C, it must be because the first successful (bad-word-free) loop iteration for the first input passed the same alphabet to tryEncodeNumbers() as the first successful loop iteration for the second input. If the remainders of the two encoded strings are also equal, then by injectivity of tryEncodeNumbers() for fixed alphabet choice their input number arrays must have also been the same. Since no restrictions were placed on the two inputs, this holds for all possible input pairs -- that is, it is impossible for encodeNumbers() to produce the same encoded output string for two different inputs.
> Decoding IDs will usually produce some kind of numeric output, but that doesn't necessarily mean that the ID is canonical. To check that the ID is valid, you can re-encode decoded numbers and check that the ID matches.
The reason this is not done automatically is that if the default blocklist changes in the future, we don't want to automatically invalidate the ID that has been generated in the past and might now be matching a new blocklist word.
1. Start with a-z.
2. Drop all vowels, numbers, most homoglyphs, and the letter 'x'.
3. Map digits 0-9 to one of the remaining letters.
4. Stringify the integer and replace the digit in each decimal place with its corresponding character.
For my use-case, all the numbers were >7 digits long, so the odds of you getting an offensive acronym were reasonably low unless you started combining them.
But there's no perfect solution. As this dataset shows, you can find offense in almost anything if you look hard enough:
California Personalized License Plate Requests Flagged for Review 2015-2016: https://docs.google.com/spreadsheets/d/18IUVU9Q4uN_lxqNd5AsN...
How does this work? Is there a review board? Is it put to public review? A few of them like "dick out" and "shtlord" are reasonable, but many of them seem so bonkers it looks like the work of trolls.
Anyway, TIL that 1970s Intel was a MS-13 gang outfit and that Octocat really means "eight vaginas".
Reason for review: hostile, insulting, or degrading
/s
Wow this is a funny peek into a weird perdicment where people need to justify that they have a good reason to have a specific license plate.
Some seems obviously ok such as:
INT13H
314 PI
And some are obviously not:
DRY(hand emoji)JOB
DICK OUT
Come to think of it: Can license plates have emojis now?!
I could give a fuck about avoiding swear words, but if you want to avoid slurs and eyebleach-inducing ideas and still have any sort of compact representation, I suspect we have to look not at problematic letters but problematic groups of letters. There's nothing intrinsically wrong with the letter E. Not with G, I, N, or R, but you can sure get a lot of attention you don't want by arranging them in the wrong order. K and Y aren't bad either, unless you're hating on Jewish people.
So maybe there's a 5:4 or a 5:3 encoding out there where you avoid making syllables.
> How can I make my IDs unique?
> The library accepts a custom alphabet from which it can generate IDs. Simply pre-shuffle the default alphabet that's provided.
I also have no idea how to use the library.. What are the 3 numbers parameters for encode()? The actual code: https://github.com/sqids/sqids-ruby/blob/main/lib/sqids.rb
I'd simply base-58 a GUID instead. At least you'll know what kind of constraints / sorting the IDs will have.