For the complete documentation index, see llms.txt. This page is also available as Markdown.

Website: Password Hashing

Step-by-step guide for web developers - make your website register, log in, change and reset passwords with AXIOM's PBKDF2-SHA256 password hashes, with full Node.js and PHP code and test vectors.

AXIOM no longer stores passwords as plain text. MEMB_INFO.memb__pwd holds a PBKDF2-SHA256 hash. Any website, CMS, launcher or admin tool that creates accounts or checks passwords must use the same hash, or players get "wrong password" in game.

This guide shows how to change your website. It covers the algorithm, the four account pages (register, login, change password, reset password), and how to test your code.

For the server side (JoinServer settings, migration of old databases) see Password Hashing.

What changed

Old website
New website

What is stored in memb__pwd

The password (secret12)

A hash record ($pbkdf2-sha256$i=15000$...)

Column type

varchar(10)

varchar(120)

How login checks the password

SQL: WHERE memb__pwd = @password

Read the record, check it in code

Register writes

The password

hashPassword(password)

Before you start

  1. The column is widened. Run the migration from Password Hashing. After it, memb__pwd is varchar(120).

  2. JoinServer is in PBKDF2 mode. In JoinServer.ini:

    [JoinServerInfo]
    PasswordHashMode = 2
    LegacyPasswordMode = 0
    PasswordHashIterations = 15000
  3. Note the iteration count. Your website must use the same PasswordHashIterations value (15000 by default).

The algorithm

PBKDF2-HMAC-SHA256 is in the standard library of every web language. You do not need a DLL, an extension, or a WebZen MD5 port.

Making a hash

  1. Make a salt: 16 random bytes from a secure random source (crypto.randomBytes, random_bytes, os.urandom). Never Math.random or rand(). New salt for every hash, including password changes.

  2. Run PBKDF2 with:

    Input
    Value

    Hash function

    SHA-256

    Password

    The password bytes exactly as typed (see Password rules)

    Salt

    The 16 bytes from step 1

    Iterations

    PasswordHashIterations (15000)

    Output length

    32 bytes

  3. Base64 the salt and the 32-byte output. Standard base64 (+ and /), with = padding.

  4. Join them into the record:

    Example (93 characters):

  5. Store the record in memb__pwd.

Checking a password

  1. Read memb__pwd for the account.

  2. If it does not start with $pbkdf2-sha256$i=, it is an old row. See Old accounts.

  3. Split the rest on $ into: iterations, base64 salt, base64 output.

  4. Run PBKDF2 again with the typed password, the decoded salt and the iterations from the record (not your config value).

  5. Compare the new 32 bytes with the decoded output using a constant-time compare (timingSafeEqual, hash_equals). Equal = correct password.

Always use the iteration count written in the record. When the server raises PasswordHashIterations, old records still have the old count, and they must keep working until they are re-hashed.

Password rules

These come from the game client and JoinServer. Enforce them on the register and change-password forms.

Rule
Why

Max 10 characters

The game client sends at most 10 characters. A 12-character website password can never log in to the game

Printable ASCII only (letters, digits, normal symbols)

The game client sends its own character bytes. Your website hashes UTF-8. They only match for plain ASCII

No trimming, no lower-casing

Passwords are case-sensitive. Hash exactly what the user typed

Username max 10 characters

memb___id is varchar(10)

Implementation

The same two helper functions serve every page: hashPassword and verifyPassword.

Built-in crypto only.

hash_pbkdf2 is in PHP core.

Python and C# versions of the same two functions are on the Password Hashing page.

Register

Validate the password, hash it, insert the record.

Keep the other columns your current register page already fills. Only memb__pwd changes.

Login

Read the record by username only, verify in code, and re-hash when asked.

Give the same error for "no such account" and "wrong password", so the login page cannot be used to find which usernames exist.

Change password

  1. Check the current password with verifyPassword, exactly like login.

  2. Validate the new one with isValidGamePassword.

  3. Write hashPassword(newPassword):

Bind @pwd as varchar(120).

There is no old password to check. Validate the new one and write hashPassword(newPassword) with the same UPDATE.

You cannot e-mail the user their old password any more - it is not stored. Replace any "send my password" feature with a reset link.

Other places that touch passwords

Search your website code for memb__pwd. Fix every hit:

You find
Change to

WHERE ... memb__pwd = @pwd

Read the row, verifyPassword in code

INSERT / UPDATE with the plain password

hashPassword(password)

sql.VarChar(10) or varchar(10) for the password parameter

varchar(120)

[dbo].[fn_md5], HASHBYTES('MD5', ...)

hashPassword / verifyPassword

Showing or e-mailing the password

Remove. Use a reset link

Stored procedures that compare passwords

Move the check into website code

Old accounts

After the migration, some rows still hold the old plain-text password until the player logs in once.

  • In game: JoinServer checks the old value once, then rewrites it as a hash.

  • On the website: the code above does the same. verifyPassword accepts the plain-text row and returns rehash: true, and the login page rewrites it.

Both sides upgrade accounts, so the database moves to hashes on its own.

When the server owner sets LegacyPasswordMode = -1 (old rows rejected), remove the plain-text branch from verifyPassword too, so the website and the game agree.

Test your code

Run your PBKDF2 call with these fixed inputs. The output must match exactly.

Password
Salt
Iterations
Expected output (hex)

password

salt (ASCII)

1

120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b

password

salt (ASCII)

2

ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43

password

salt (ASCII)

4096

c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a

JoinServer runs the same three checks at startup and refuses to start if any fails, so these values are what the server produces.

Full record test: verifyPassword("Test1234", record) must return valid for this record, and invalid for Test1235:

(Salt = bytes 00 01 02 ... 0F.)

Last test - in game:

  1. Register a new account on the website.

  2. In SQL, check memb__pwd starts with $pbkdf2-sha256$i=15000$ and is 93 characters long.

  3. Log in with the game client. It must work.

  4. Change the password on the website, log in to the game with the new one.

Common Issues

  • Password Hashing - server settings, database migration, Python and C# code

  • JoinServer.ini - PasswordHashMode, LegacyPasswordMode, PasswordHashIterations, CaseSensitive

Last updated