Website: Password Hashing

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 websiteNew website
What is stored in memb__pwdThe password (secret12)A hash record ($pbkdf2-sha256$i=15000$...)
Column typevarchar(10)varchar(120)
How login checks the passwordSQL: WHERE memb__pwd = @passwordRead the record, check it in code
Register writesThe passwordhashPassword(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:

    InputValue
    Hash functionSHA-256
    PasswordThe password bytes exactly as typed (see Password rules)
    SaltThe 16 bytes from step 1
    IterationsPasswordHashIterations (15000)
    Output length32 bytes
  3. Base64 the salt and the 32-byte output. Standard base64 (+ and /), with = padding.

  4. Join them into the record:

    $pbkdf2-sha256$i=<iterations>$<base64 salt>$<base64 output>
    

    Example (93 characters):

    $pbkdf2-sha256$i=15000$AAECAwQFBgcICQoLDA0ODw==$0D2Qc/Uk7HghEtNbPYBILfYyEF8V+1a3qqW2pDlcxuQ=
    
  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.

Password rules#

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

RuleWhy
Max 10 charactersThe 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-casingPasswords are case-sensitive. Hash exactly what the user typed
Username max 10 charactersmemb___id is varchar(10)

Implementation#

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

Built-in crypto only.

// password.ts
import crypto from "crypto";

const PREFIX = "$pbkdf2-sha256$i=";
const ITERATIONS = 15000;   // = PasswordHashIterations in JoinServer.ini

export function hashPassword(password: string): string {
  const salt = crypto.randomBytes(16);
  const output = crypto.pbkdf2Sync(password, salt, ITERATIONS, 32, "sha256");
  return `${PREFIX}${ITERATIONS}$${salt.toString("base64")}$${output.toString("base64")}`;
}

export interface VerifyResult {
  valid: boolean;
  rehash: boolean;   // true = write hashPassword(password) back to the row
}

export function verifyPassword(password: string, stored: string): VerifyResult {
  if (!stored.startsWith(PREFIX)) {
    // Old plain-text row (LegacyPasswordMode = 0)
    const valid = stored.length > 0 && password === stored;
    return { valid, rehash: valid };
  }

  const parts = stored.slice(PREFIX.length).split("$");
  if (parts.length !== 3) return { valid: false, rehash: false };

  const iterations = parseInt(parts[0], 10);
  const salt = Buffer.from(parts[1], "base64");
  const expected = Buffer.from(parts[2], "base64");
  if (!(iterations >= 1 && iterations <= 10_000_000) || expected.length !== 32) {
    return { valid: false, rehash: false };
  }

  const actual = crypto.pbkdf2Sync(password, salt, iterations, 32, "sha256");
  const valid = crypto.timingSafeEqual(actual, expected);
  return { valid, rehash: valid && iterations < ITERATIONS };
}

export function isValidGamePassword(password: string): boolean {
  return /^[\x21-\x7E]{4,10}$/.test(password);   // printable ASCII, 4-10 chars
}

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

Register#

Validate the password, hash it, insert the record.

import sql from "mssql";
import { hashPassword, isValidGamePassword } from "./password";

if (!isValidGamePassword(password)) throw new Error("Password: 4-10 letters, digits or symbols");

await pool.request()
  .input("id", sql.VarChar(10), username)
  .input("pwd", sql.VarChar(120), hashPassword(password))   // 120, not 10
  .input("mail", sql.VarChar(50), email)
  .query(`INSERT INTO MEMB_INFO (memb___id, memb__pwd, memb_name, sno__numb, bloc_code, ctl1_code, mail_addr, appl_days)
          VALUES (@id, @pwd, @id, '1111111111111', '0', '0', @mail, GETDATE())`);

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.

import { hashPassword, verifyPassword } from "./password";

const r = await pool.request()
  .input("id", sql.VarChar(10), username)
  .query("SELECT memb__pwd FROM MEMB_INFO WHERE memb___id = @id");

const stored = r.recordset[0]?.memb__pwd ?? "";
const check = verifyPassword(password, String(stored));

if (!check.valid) return fail("Wrong username or password");

if (check.rehash) {
  // Old plain-text row or old iteration count: upgrade it now. Never block the login on this.
  await pool.request()
    .input("id", sql.VarChar(10), username)
    .input("pwd", sql.VarChar(120), hashPassword(password))
    .query("UPDATE MEMB_INFO SET memb__pwd = @pwd WHERE memb___id = @id")
    .catch(() => {});
}

// ... start the session

Change password#

  1. Check the current password with verifyPassword, exactly like login.
  2. Validate the new one with isValidGamePassword.
  3. Write hashPassword(newPassword):
UPDATE MEMB_INFO SET memb__pwd = @pwd WHERE memb___id = @id

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 findChange to
WHERE ... memb__pwd = @pwdRead the row, verifyPassword in code
INSERT / UPDATE with the plain passwordhashPassword(password)
sql.VarChar(10) or varchar(10) for the password parametervarchar(120)
[dbo].[fn_md5], HASHBYTES('MD5', ...)hashPassword / verifyPassword
Showing or e-mailing the passwordRemove. Use a reset link
Stored procedures that compare passwordsMove 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.

PasswordSaltIterationsExpected output (hex)
passwordsalt (ASCII)1120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b
passwordsalt (ASCII)2ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43
passwordsalt (ASCII)4096c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a

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:

$pbkdf2-sha256$i=15000$AAECAwQFBgcICQoLDA0ODw==$0D2Qc/Uk7HghEtNbPYBILfYyEF8V+1a3qqW2pDlcxuQ=

(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 October 1, 2026