Register & Login API (Token)
How token login works
The app sends email + password once. If they are right, the server creates a long random token and the app saves it (in DataStore). After that, every request carries the token in a header, and the server knows who is calling.
Passwords: hash, never store
password_hash() turns a password into a one-way hash with a random salt (bcrypt). Even you, the owner, can never read the password back. password_verify() checks a typed password against the hash.
<?php
$hash = password_hash('Test@123', PASSWORD_DEFAULT);
echo $hash, "\n";
var_dump(password_verify('Test@123', $hash));
var_dump(password_verify('test@123', $hash));Login replies "Wrong email or password." whether the email does not exist or the password is wrong. A different message would let attackers find out which emails have accounts.
Tokens: create and check
These two helpers (already in helpers.php) do all the token work. Any endpoint that needs a logged-in user simply calls $user = require_auth(); on its first lines.
<?php
// From includes/helpers.php
/** Create a new login token for a user. Only its sha256 hash is stored. */
function create_token($userId)
{
$token = bin2hex(random_bytes(32)); // 32 random bytes = 64 hex characters
$stmt = db()->prepare('INSERT INTO user_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)');
$stmt->execute(array($userId, hash('sha256', $token), date('Y-m-d H:i:s', time() + TOKEN_DAYS * 86400)));
return $token; // the app gets the real token ONCE
}
/** Returns the logged-in user row, or stops with 401. */
function require_auth()
{
$token = bearer_token(); // reads "Authorization: Bearer <token>"
if ($token === null) {
fail('Please login to continue.', 401);
}
$stmt = db()->prepare(
'SELECT u.id, u.name, u.email, u.phone, u.created_at FROM user_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.token_hash = ? AND t.expires_at > NOW()'
);
$stmt->execute(array(hash('sha256', $token)));
$user = $stmt->fetch();
if (!$user) {
fail('Session expired. Please login again.', 401);
}
$user['id'] = (int) $user['id'];
return $user;
}What the app keeps
- token = c4a45ae77d…(64 hex)
- saved in DataStore
- sent in the Authorization header
What the database keeps
- token_hash = sha256(token)
- user_id, expires_at (30 days)
- one row per logged-in device
Step 1: auth/register.php
Validate everything on the server — the app also validates, but anyone can call an API directly. Then check the email is free, save the user with a hashed password and log them in straight away.
<?php
require_once __DIR__ . '/../../includes/helpers.php';
require_method('POST');
require_fields(array('name' => 'Name', 'email' => 'Email', 'password' => 'Password'));
$name = in_str('name');
$email = strtolower(in_str('email'));
$phone = in_str('phone');
$password = in_str('password');
if (mb_strlen($name) < 2 || mb_strlen($name) > 80) {
fail('Name must be 2 to 80 characters.', 422);
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
fail('Please enter a valid email.', 422);
}
if ($phone !== '' && !preg_match('/^[6-9][0-9]{9}$/', $phone)) {
fail('Please enter a valid 10-digit mobile number.', 422);
}
if (strlen($password) < 6) {
fail('Password must be at least 6 characters.', 422);
}
$stmt = db()->prepare('SELECT id FROM users WHERE email = ?');
$stmt->execute(array($email));
if ($stmt->fetch()) {
fail('This email is already registered. Please login.', 409);
}
$stmt = db()->prepare('INSERT INTO users (name, email, phone, password_hash) VALUES (?, ?, ?, ?)');
$stmt->execute(array($name, $email, $phone === '' ? null : $phone, password_hash($password, PASSWORD_DEFAULT)));
$userId = (int) db()->lastInsertId();
ok(array(
'token' => create_token($userId),
'user' => array('id' => $userId, 'name' => $name, 'email' => $email, 'phone' => $phone === '' ? null : $phone),
), 'Account created successfully.');Step 2: auth/login.php
<?php
require_once __DIR__ . '/../../includes/helpers.php';
require_method('POST');
require_fields(array('email' => 'Email', 'password' => 'Password'));
$email = strtolower(in_str('email'));
$password = in_str('password');
$stmt = db()->prepare('SELECT id, name, email, phone, password_hash FROM users WHERE email = ?');
$stmt->execute(array($email));
$user = $stmt->fetch();
// Same message for "no such email" and "wrong password": do not reveal which emails exist.
if (!$user || !password_verify($password, $user['password_hash'])) {
fail('Wrong email or password.', 401);
}
// Remove this user's expired tokens while we are here.
db()->prepare('DELETE FROM user_tokens WHERE user_id = ? AND expires_at < NOW()')->execute(array($user['id']));
ok(array(
'token' => create_token((int) $user['id']),
'user' => array('id' => (int) $user['id'], 'name' => $user['name'], 'email' => $user['email'], 'phone' => $user['phone']),
), 'Welcome back, ' . $user['name'] . '!');Step 3: me, update, logout
me.php returns the profile plus three counters the Account screen shows (orders, wishlist, cart) in a single query with sub-selects.
<?php
require_once __DIR__ . '/../../includes/helpers.php';
require_method('GET');
$user = require_auth();
$counts = db()->prepare(
'SELECT
(SELECT COUNT(*) FROM orders WHERE user_id = ?) AS orders,
(SELECT COUNT(*) FROM wishlist WHERE user_id = ?) AS wishlist,
(SELECT COALESCE(SUM(qty), 0) FROM cart_items WHERE user_id = ?) AS cart'
);
$counts->execute(array($user['id'], $user['id'], $user['id']));
$c = $counts->fetch();
$user['order_count'] = (int) $c['orders'];
$user['wishlist_count'] = (int) $c['wishlist'];
$user['cart_count'] = (int) $c['cart'];
ok($user);update.php changes name and phone, and the password only if the current password is correct:
<?php
require_once __DIR__ . '/../../includes/helpers.php';
require_method('POST');
$user = require_auth();
require_fields(array('name' => 'Name'));
$name = in_str('name');
$phone = in_str('phone');
if (mb_strlen($name) < 2 || mb_strlen($name) > 80) {
fail('Name must be 2 to 80 characters.', 422);
}
if ($phone !== '' && !preg_match('/^[6-9][0-9]{9}$/', $phone)) {
fail('Please enter a valid 10-digit mobile number.', 422);
}
db()->prepare('UPDATE users SET name = ?, phone = ? WHERE id = ?')
->execute(array($name, $phone === '' ? null : $phone, $user['id']));
// Optional password change: needs the current password.
$newPass = in_str('new_password');
if ($newPass !== '') {
$stmt = db()->prepare('SELECT password_hash FROM users WHERE id = ?');
$stmt->execute(array($user['id']));
if (!password_verify(in_str('current_password'), $stmt->fetchColumn())) {
fail('Current password is wrong.', 422);
}
if (strlen($newPass) < 6) {
fail('New password must be at least 6 characters.', 422);
}
db()->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
->execute(array(password_hash($newPass, PASSWORD_DEFAULT), $user['id']));
}
ok(array('id' => $user['id'], 'name' => $name, 'email' => $user['email'], 'phone' => $phone === '' ? null : $phone), 'Profile updated.');logout.php deletes this device's token. Other devices stay logged in.
<?php
require_once __DIR__ . '/../../includes/helpers.php';
require_method('POST');
require_auth();
db()->prepare('DELETE FROM user_tokens WHERE token_hash = ?')->execute(array(hash('sha256', bearer_token())));
ok(null, 'Logged out.');Step 4: Test with curl
# Login (JSON body)
curl -X POST http://localhost:8000/api/auth/login.php \
-H "Content-Type: application/json" \
-d '{"email":"test@karteasy.in","password":"Test@123"}'
# Copy the token from the answer, then call a protected endpoint
TOKEN=paste-the-64-character-token-here
curl http://localhost:8000/api/auth/me.php -H "Authorization: Bearer $TOKEN"
# Logout
curl -X POST http://localhost:8000/api/auth/logout.php -H "Authorization: Bearer $TOKEN"These are the real answers from the KartEasy API:
Common mistakes
- Authorization header missing on hosting → keep the RewriteRule in
backend/.htaccess. - Comparing passwords with
==→ alwayspassword_verify(). - Returning
password_hashin JSON → select only the columns you need. - Using
rand()oruniqid()for tokens → they are guessable; userandom_bytes(). - Never expiring tokens → we keep 30 days and clean old ones on login.
