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 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#
-
The column is widened. Run the migration from Password Hashing. After it,
memb__pwdisvarchar(120). -
JoinServer is in PBKDF2 mode. In
JoinServer.ini:[JoinServerInfo] PasswordHashMode = 2 LegacyPasswordMode = 0 PasswordHashIterations = 15000 -
Note the iteration count. Your website must use the same
PasswordHashIterationsvalue (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#
-
Make a salt: 16 random bytes from a secure random source (
crypto.randomBytes,random_bytes,os.urandom). NeverMath.randomorrand(). New salt for every hash, including password changes. -
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 -
Base64 the salt and the 32-byte output. Standard base64 (
+and/), with=padding. -
Join them into the record:
$pbkdf2-sha256$i=<iterations>$<base64 salt>$<base64 output>Example (93 characters):
$pbkdf2-sha256$i=15000$AAECAwQFBgcICQoLDA0ODw==$0D2Qc/Uk7HghEtNbPYBILfYyEF8V+1a3qqW2pDlcxuQ= -
Store the record in
memb__pwd.
Checking a password#
- Read
memb__pwdfor the account. - If it does not start with
$pbkdf2-sha256$i=, it is an old row. See Old accounts. - Split the rest on
$into: iterations, base64 salt, base64 output. - Run PBKDF2 again with the typed password, the decoded salt and the iterations from the record (not your config value).
- 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.
| 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.
// 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
}
hash_pbkdf2 is in PHP core.
<?php
// password.php
const PW_PREFIX = '$pbkdf2-sha256$i=';
const PW_ITERATIONS = 15000; // = PasswordHashIterations in JoinServer.ini
function hashPassword(string $password): string {
$salt = random_bytes(16);
$output = hash_pbkdf2('sha256', $password, $salt, PW_ITERATIONS, 32, true);
return PW_PREFIX . PW_ITERATIONS . '$' . base64_encode($salt) . '$' . base64_encode($output);
}
/** @return array{valid: bool, rehash: bool} */
function verifyPassword(string $password, string $stored): array {
if (strpos($stored, PW_PREFIX) !== 0) {
// Old plain-text row (LegacyPasswordMode = 0)
$valid = $stored !== '' && hash_equals($stored, $password);
return ['valid' => $valid, 'rehash' => $valid];
}
$parts = explode('$', substr($stored, strlen(PW_PREFIX)));
if (count($parts) !== 3) return ['valid' => false, 'rehash' => false];
$iterations = (int)$parts[0];
$salt = base64_decode($parts[1], true);
$expected = base64_decode($parts[2], true);
if ($iterations < 1 || $iterations > 10000000 || $salt === false || $expected === false || strlen($expected) !== 32) {
return ['valid' => false, 'rehash' => false];
}
$actual = hash_pbkdf2('sha256', $password, $salt, $iterations, 32, true);
$valid = hash_equals($expected, $actual);
return ['valid' => $valid, 'rehash' => $valid && $iterations < PW_ITERATIONS];
}
function isValidGamePassword(string $password): bool {
return preg_match('/^[\x21-\x7E]{4,10}$/', $password) === 1; // 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())`);
if (!isValidGamePassword($password)) throw new Exception('Password: 4-10 letters, digits or symbols');
$st = $db->prepare("INSERT INTO MEMB_INFO (memb___id, memb__pwd, memb_name, sno__numb, bloc_code, ctl1_code, mail_addr, appl_days)
VALUES (?, ?, ?, '1111111111111', '0', '0', ?, GETDATE())");
$st->execute([$username, hashPassword($password), $username, $email]);
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
$st = $db->prepare('SELECT memb__pwd FROM MEMB_INFO WHERE memb___id = ?');
$st->execute([$username]);
$stored = (string)($st->fetchColumn() ?: '');
$check = verifyPassword($password, $stored);
if (!$check['valid']) fail('Wrong username or password');
if ($check['rehash']) {
// Old plain-text row or old iteration count: upgrade it now.
try {
$db->prepare('UPDATE MEMB_INFO SET memb__pwd = ? WHERE memb___id = ?')
->execute([hashPassword($password), $username]);
} catch (Throwable $e) { /* never block the login on this */ }
}
// ... start the session
Change password#
- Check the current password with
verifyPassword, exactly like login. - Validate the new one with
isValidGamePassword. - Write
hashPassword(newPassword):
UPDATE MEMB_INFO SET memb__pwd = @pwd WHERE memb___id = @id
Bind @pwd as varchar(120).
Reset password (e-mail link, admin panel)#
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.
verifyPasswordaccepts the plain-text row and returnsrehash: 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:
$pbkdf2-sha256$i=15000$AAECAwQFBgcICQoLDA0ODw==$0D2Qc/Uk7HghEtNbPYBILfYyEF8V+1a3qqW2pDlcxuQ=
(Salt = bytes 00 01 02 ... 0F.)
Last test - in game:
- Register a new account on the website.
- In SQL, check
memb__pwdstarts with$pbkdf2-sha256$i=15000$and is 93 characters long. - Log in with the game client. It must work.
- Change the password on the website, log in to the game with the new one.
Common Issues#
Related pages#
- Password Hashing - server settings, database migration, Python and C# code
- JoinServer.ini -
PasswordHashMode,LegacyPasswordMode,PasswordHashIterations,CaseSensitive