How to build a secure account registration system for MU Online
Build a PHP account registration system for your MU Online server that is hardened against SQL injection, weak passwords and bots, using prepared statements, correct hashing and strict validation.
The registration form is the front door of your MU Online server and, at the same time, one of the favorite targets for anyone who wants to attack it. A poorly built registration opens the way to SQL injection (which can destroy or leak your entire database), allows ridiculously weak passwords, lets
The registration form is the front door of your MU Online server and, at the same time, one of the favorite targets for anyone who wants to attack it. A poorly built registration opens the way to SQL injection (which can destroy or leak your entire database), allows ridiculously weak passwords, lets bots create thousands of phantom accounts and, in the worst case, exposes the players' credentials. This advanced-level tutorial shows how to build a registration system in PHP that resists these attacks using the correct pillars: prepared statements, password hashing, strict server-side validation and defense against automation. The focus is on concept and real security, not blind copy-and-paste.
First of all, understand the threat model. Registration receives data that comes from outside, that is, from people who may be malicious. The number-one rule of security is: all input is hostile until proven otherwise. Every form field must be treated as a potential attack. From that mindset, every technical decision in this tutorial makes sense.
Prerequisites
- A working MU server and database. If you are still at the very beginning, start with the guide on how to create a MU Online server.
- PHP 7.4+ or 8.x with PDO and the driver for your DBMS (usually SQL Server via
sqlsrv/PDO). - HTTPS configured on the domain (Let's Encrypt handles it for free).
- Knowledge of your emulator's account schema: the name of the table and of the user/password/email columns.
- A low-privilege database user for the website.
| Threat | Vector | Defense in this tutorial |
|---|---|---|
| SQL injection | Form fields concatenated into the query | Prepared statements, always |
| Password theft | Password in plain text in the database or in transit | Hashing + HTTPS |
| Bots/spam | Automated mass registration | CAPTCHA, rate limit, honeypot, email |
| Brute force | Password guessing | Rate limiting and mandatory strong passwords |
| Account enumeration | Messages that reveal whether a user exists | Generic responses |
> Warning: the table and column names (MEMB_INFO, memb___id, memb__pwd, mail_addr) are examples from the Season 6 line. The actual format, including the expected password algorithm, varies by emulator. Confirm before implementing.
The password-hashing dilemma in MU
Here is the central tension of any MU registration. The absolute ideal for security is to store passwords with a strong, slow algorithm like bcrypt/Argon2 (password_hash() in PHP). The problem: the game emulator needs to validate that same password at login, and many old emulators only understand MD5 or even plain text. You do not control the algorithm in isolation; it has to match what the game server expects.
So there are two situations:
- A modern emulator that supports strong hashing — use
password_hash()and live happily. - A legacy emulator locked to MD5 — you are limited to the format the game accepts. In that case, mitigate the risk with mandatory HTTPS, rate limiting, monitoring and never reusing that database for any other purpose.
Always confirm the expected format, because it varies by version. Below I show both paths.
Step 1: Connect to the database with PDO
Never use the old API without prepared statements. Configure PDO to force exceptions and disable prepared-statement emulation:
<?php
// db.php
function getConnection(): PDO {
$dsn = 'sqlsrv:Server=203.0.113.10,1433;Database=MuOnline';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // real prepared statements, not emulated
];
return new PDO($dsn, 'web_mu', getenv('DB_PASS'), $options);
}
Note that the database password comes from an environment variable (getenv), never hardcoded in the versioned file.
Step 2: Validate all input on the server
JavaScript validation is convenience, not security, because it is trivial to bypass. Every rule must be enforced in PHP:
<?php
function validarRegistro(array $in): array {
$erros = [];
// Username: 4-10 alphanumeric characters (common MU limit)
if (!preg_match('/^[a-zA-Z0-9]{4,10}$/', $in['usuario'] ?? '')) {
$erros[] = 'Username must be 4 to 10 alphanumeric characters.';
}
// Valid email
if (!filter_var($in['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
$erros[] = 'Invalid email.';
}
// Strong password: minimum 8, with a letter and a number
$senha = $in['senha'] ?? '';
if (strlen($senha) < 8 || !preg_match('/[A-Za-z]/', $senha) || !preg_match('/[0-9]/', $senha)) {
$erros[] = 'Password must have at least 8 characters, with letters and numbers.';
}
// Password confirmation
if ($senha !== ($in['senha2'] ?? '')) {
$erros[] = 'The passwords do not match.';
}
return $erros;
}
The username length limit also exists because the field in the MU database is usually short (varchar(10)), and going over it truncates or breaks in-game login.
Step 3: Check uniqueness with a prepared statement
Before inserting, check whether the user already exists, always with a placeholder, never concatenating:
<?php
function usuarioExiste(PDO $pdo, string $usuario): bool {
// NEVER: "SELECT ... WHERE memb___id = '$usuario'" <- injection!
$stmt = $pdo->prepare('SELECT 1 FROM MEMB_INFO WHERE memb___id = ? ');
$stmt->execute([$usuario]); // data treated as data, not as SQL
return (bool) $stmt->fetchColumn();
}
The difference between the commented line and the real one is the difference between a secure database and a database destroyed by a '; DROP TABLE MEMB_INFO;--.
Step 4: Insert the account with the appropriate hash
Here we apply the hashing decision. The ideal path, for a modern emulator:
<?php
$hash = password_hash($senha, PASSWORD_DEFAULT); // bcrypt/Argon2, with automatic salt
$stmt = $pdo->prepare(
'INSERT INTO MEMB_INFO (memb___id, memb__pwd, mail_addr, bloc_code, ctl1_code)
VALUES (?, ?, ?, 0, 0)'
);
$stmt->execute([$usuario, $hash, $email]);
The legacy path, for an emulator locked to MD5 (less secure, use only if required):
<?php
// Only because the emulator requires it. Mitigate with HTTPS + rate limit + monitoring.
$hashLegado = strtoupper(md5($senha)); // format varies by emulator
$stmt = $pdo->prepare(
'INSERT INTO MEMB_INFO (memb___id, memb__pwd, mail_addr) VALUES (?, ?, ?)'
);
$stmt->execute([$usuario, $hashLegado, $email]);
In both cases, the password never goes into the database in plain text and the query uses placeholders.
Step 5: Defense against bots and automation
An open form attracts mass registration. Combine layers, because none of them is enough on its own:
- CAPTCHA (reCAPTCHA/hCaptcha/Turnstile) validates that there is a human.
- Honeypot: an invisible field that humans do not fill in, but bots do. If it comes back filled, reject it.
- Per-IP rate limiting: at most N registrations per IP per hour.
- Email verification: the account is only activated after clicking the link that was sent.
The honeypot is cheap and effective:
<?php
// Field hidden by CSS in the form: <input name="website" style="display:none">
if (!empty($_POST['website'])) {
// A human does not see this field; if it came filled, it is a bot.
http_response_code(400);
exit('Invalid request.');
}
Simple rate limiting by logging attempts:
<?php
function dentroDoLimite(PDO $pdo, string $ip, int $max = 5): bool {
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM registro_log
WHERE ip = ? AND criado_em > DATEADD(hour, -1, GETDATE())'
);
$stmt->execute([$ip]);
return (int) $stmt->fetchColumn() < $max;
}
Step 6: Protect against CSRF
To prevent another site from forcing a submission of your form, generate a per-session token and validate it on POST:
<?php
session_start();
// When displaying the form:
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
// In the HTML: <input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
// When processing the POST:
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(403);
exit('Invalid token.');
}
Step 7: Generic responses and HTTPS
Avoid messages that reveal whether a user or email already exists, because that enables account enumeration. Prefer "If the details are correct, you will receive an email." And always force HTTPS, redirecting any HTTP access, so that the password and data never travel in plain text.
<?php
// Force HTTPS
if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === 'off') {
header('Location: https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'], true, 301);
exit;
}
Step 8: Log everything for auditing
Log registration attempts (IP, time, result) without recording the password. These logs let you detect attacks, block abusive IPs and investigate incidents. Never log the password, not even hashed.
Common errors and fixes
| Error | Consequence | Fix |
|---|---|---|
| Concatenating input into the query | SQL injection, compromised database | Use prepared statements in all queries |
| Storing the password in plain text | Total leak in case of a breach | Hash with password_hash (or the emulator's format) |
| Validating only in JavaScript | Trivial bypass, dirty data in the database | Enforce all validation on the server |
| No HTTPS | Password captured in transit | Let's Encrypt certificate and forced redirect |
| Form without anti-bot | Thousands of phantom accounts | CAPTCHA + honeypot + rate limit + email |
| Specific error messages | Account enumeration | Generic responses |
| Database password in versioned code | Credential exposed in the repository | Use an environment variable |
| No CSRF token | Registration forced by third parties | Per-session token validated with hash_equals |
Launch checklist
- PDO connection with
ERRMODE_EXCEPTIONandEMULATE_PREPARES=false - All queries use prepared statements, no exceptions
- Password never stored in plain text
- Hash format confirmed with the emulator (varies by version)
- Full validation enforced on the server
- Username length limit compatible with the database column
- HTTPS mandatory with HTTP redirect
- CAPTCHA active on the form
- Invisible honeypot implemented
- Per-IP rate limiting working
- Email verification before activating the account
- CSRF token generated and validated
- Generic messages to avoid enumeration
- Database credentials in environment variables
- Attempt logs without recording the password
- Low-privilege database user
A secure registration system is not a single trick, but the sum of several layers: prepared statements that shut the injection door, hashing that protects passwords even in case of a leak, server validation that rejects junk, and anti-bot defenses that keep the database clean. No layer is optional. The player trusts you with their password, often the same one they use elsewhere, and that trust is the most valuable asset of your server. Build registration thinking the way an attacker would, and you will have a solid foundation on which everything else in the project can grow with peace of mind.
Frequently asked questions
Can I use MD5 for passwords because the emulator uses it?
Many old emulators store passwords as MD5, or even plain text, for compatibility. If yours requires it, you are stuck with that format for in-game login to work. The ideal is to use an emulator that supports strong hashing; if that is not possible, mitigate with HTTPS, rate limiting and monitoring, and never reuse that database for anything other than the game.
Do prepared statements really prevent SQL injection?
Yes, when used correctly. They separate the SQL command from the data, so user input is never interpreted as code. The mistake is concatenating input into the query even when prepared statements are available, or using prepared statements in only some of the queries. Use them everywhere, no exceptions.
How do I stop bots from creating thousands of accounts?
Combine several layers: a CAPTCHA on the form, per-IP rate limiting, email verification and an invisible honeypot. None of them solves it alone, but together they raise the cost of an attack considerably. Also log attempts so you can spot abuse patterns and block repeat-offender IPs.
Do I need HTTPS even on a small server?
Yes, always. Without HTTPS, the password and data travel in plain text and anyone on the same network can capture everything. Free certificates from Let's Encrypt make this trivial and free. There is no excuse for account registration without HTTPS in 2024.
Where should registration data be validated?
Always on the server, in PHP. JavaScript validation improves the user experience, but it is trivial to bypass, so it is convenience only. Every rule that protects the database (format, length, uniqueness, allowed characters) must be enforced in the backend before it touches the SQL.