Based on the comments, I came to the following solution.
First, I render a LUID of 8 characters as described by nathanchere in this SO thread.
public class LUID
{
private static readonly RNGCryptoServiceProvider RandomGenerator
= new RNGCryptoServiceProvider();
private static readonly char[] ValidCharacters =
"ABCDEFGHJKLMNPQRSTUVWXYZ23456789".ToCharArray();
public const int DefaultLength = 6;
private static int counter = 0;
public static string Generate(int length = DefaultLength)
{
var randomData = new byte[length];
RandomGenerator.GetNonZeroBytes(randomData);
var result = new StringBuilder(DefaultLength);
foreach (var value in randomData)
{
counter = (counter + value) % (ValidCharacters.Length - 1);
result.Append(ValidCharacters[counter]);
}
return result.ToString();
}
}
Then I append a check digit calculated with the iso7064 MOD 1271,36 standard as described here.
I also added a small check that if the calculated check digit contains O, 0, I or 1, I regenerate the code again until it doesn't contain these characters anymore.
The result is a code like 6VXA35YDCE that is fairly unique (if my math is correct there should be like 1.099.511.627.776 possible combinations). It also does not contain I, 1, O and 0; to avoid confusion.
Also, I make sure that any generated LUID does not exist yet in the database before I create a new user with it, just to be sure that it keeps working in case collisions occur.
I think this meets the requirements I was looking for...