Free tutorials & notes in Hindi & English · Clean code examples 🧰 Free PDF & Image Tools →
🛠️ PHP + MySQL REST API  ·  Lesson 12

API Security & Testing

Top threats and our defence

Treat the Android app as untrusted: anyone can copy its requests and change them. Everything that matters is decided on the server.

ThreatExampleKartEasy defenceWhere
SQL injectionq=' OR 1=1 --PDO prepared statements, whitelisted ORDER BYevery endpoint
IDORopen order id=1 of another userAND user_id = ? in every querycart, address, orders
Price tamperingsend "price": 1prices and totals only from the databasecart.php, place.php
Oversellingtwo buyers, one itemtransaction + SELECT … FOR UPDATEorders/place.php
Stolen password DBbackup leakedpassword_hash, tokens stored as SHA-256auth, user_tokens
Fake reviewsreview without buyingonly delivered ordersreviews/add.php
XSS in adminname = <script>h() on every outputadmin/*
CSRF in adminhidden form on another siteCSRF token + hash_equalsadmin/*
Malicious uploadshell.php.jpggetimagesize, random name, no PHP in uploadsadmin/inc/bootstrap.php
Info leakSQL error shown to usersDEBUG off, errors only in the loghelpers.php
Terminal – try to cheat the price
# Attacker tries to buy at ₹1 by sending a price
curl -X POST $B/cart/add.php -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"product_id":7,"qty":1,"price":1}'
"price" is simply ignored: add.php reads only product_id and qty. The cart still shows "price": 1899 from the products table.
Terminal – try SQL injection
# SQL injection attempt in the search box
curl "$B/products.php?q=' OR '1'='1&sort=price_asc;DROP TABLE users"
HTTP 200 {"success":true,"message":"OK","data":{"items":[],"page":1,"limit":20,"total":0,"has_more":false}} → the text is searched literally (placeholders) and the unknown sort falls back to "newest".

Security checklist

  1. 1HTTPS on the domain (free SSL), BASE_URL starts with https://
  2. 2DEBUG is false in config.php
  3. 3Admin password changed; admin folder optionally renamed
  4. 4includes/, tests/ and database.sql return 403 in the browser
  5. 5Uploading a .php file in the admin panel is refused
  6. 6Every customer query that uses an id also checks user_id
  7. 7Smoke test: 26 passed, 0 failed
Tick all seven before you publish the app

Errors and logs

When something breaks, users see a friendly message while you see the real reason in the error log (Hostinger: hPanel → Advanced → Error logs, or error_log file in the folder).

PHP – includes/helpers.php
<?php
// helpers.php: every uncaught error
set_exception_handler(function ($e) {
    error_log('[KartEasy] ' . $e->getMessage());          // goes to the server error log
    $msg = DEBUG ? $e->getMessage() : 'Something went wrong. Please try again.';
    json_out(array('success' => false, 'message' => $msg), 500);
});

DEBUG = true (your PC)

  • real message in JSON
  • e.g. "Unknown column 'prise'"
  • fast to fix bugs

DEBUG = false (live)

  • "Something went wrong."
  • details only in error_log
  • nothing useful for attackers
Same code, different safety

Testing with curl

curl comes with Windows 10+, macOS and Linux. It is the quickest way to try an endpoint while you write it.

Terminal
B=http://localhost:8000/api

# 1. Public endpoints
curl $B/home.php
curl "$B/products.php?q=watch&sort=price_asc"

# 2. Login and keep the token in a variable (needs jq: apt/brew install jq)
TOKEN=$(curl -s -X POST $B/auth/login.php -H 'Content-Type: application/json' \
  -d '{"email":"test@karteasy.in","password":"Test@123"}' | jq -r .data.token)

# 3. Protected endpoints
curl -X POST $B/cart/add.php -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"product_id":7,"qty":1}'
curl $B/cart/list.php -H "Authorization: Bearer $TOKEN"

# 4. See the HTTP status too
curl -s -o /dev/null -w "%{http_code}\n" $B/product.php?id=999   # 404

Testing with Postman

  1. 1Create a collection KartEasy and a variable base = http://localhost:8000/api
  2. 2Add POST {{base}}/auth/login.php, Body → raw → JSON with email and password
  3. 3In the request Tests tab: pm.collectionVariables.set("token", pm.response.json().data.token)
  4. 4On the collection → Authorization → Bearer Token → {{token}} (every request inherits it)
  5. 5Add the other endpoints, then Run collection to test them all in order
A reusable Postman collection in five steps
🔒 Postman – KartEasyPOST {{base}}/cart/add.php Send ▶Authorization: Bearer {{token}} Body: {"product_id":7,"qty":1}200 OK · 41 ms · "message": "Added to cart.""item_count": 1, "subtotal": 1899, "total": 1899

Automatic smoke test (26 checks)

Clicking through endpoints by hand after every change is slow. tests/smoke-test.php runs a whole customer journey and checks each answer. It uses only file_get_contents, so it runs anywhere PHP runs — no extra library.

PHP – backend/tests/smoke-test.php
<?php
/*
 * KartEasy API smoke test – runs a full shopping flow and checks every answer.
 *
 *   php tests/smoke-test.php http://localhost:8000
 *   php tests/smoke-test.php https://yourdomain.com/karteasy
 *
 * It creates a new test customer each time (test+<time>@example.com), so it is
 * safe to run again and again. Run it from the command line only.
 */
if (PHP_SAPI !== 'cli') {
    http_response_code(404);
    exit;
}

$base = rtrim(isset($argv[1]) ? $argv[1] : 'http://localhost:8000', '/') . '/api/';
$token = null;
$passed = 0;
$failed = 0;

/** Sends one request and returns array(httpStatus, decodedJson). */
function call($method, $path, $body = null)
{
    global $base, $token;
    $headers = array('Accept: application/json');
    if ($body !== null) {
        $headers[] = 'Content-Type: application/json';
    }
    if ($token !== null) {
        $headers[] = 'Authorization: Bearer ' . $token;
    }
    $context = stream_context_create(array('http' => array(
        'method' => $method,
        'header' => implode("\r\n", $headers),
        'content' => $body === null ? '' : json_encode($body),
        'ignore_errors' => true,      // also read the body of 4xx / 5xx answers
        'timeout' => 15,
    )));
    $raw = @file_get_contents($base . $path, false, $context);
    $status = 0;
    if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
        $status = (int) $m[1];
    }
    return array($status, json_decode((string) $raw, true));
}

function check($label, $ok, $extra = '')
{
    global $passed, $failed;
    if ($ok) {
        $passed++;
        echo "  PASS  $label\n";
    } else {
        $failed++;
        echo "  FAIL  $label  $extra\n";
    }
}

echo "KartEasy API test → $base\n\n";

list($s, $r) = call('GET', '');
check('API is running and DB connected', $s === 200 && $r['data']['database'] === 'connected');

list($s, $r) = call('GET', 'home.php');
check('home.php has banners, categories and products', $s === 200 && count($r['data']['banners']) > 0 && count($r['data']['new_arrivals']) > 0);
$productId = $r['data']['new_arrivals'][0]['id'];

list($s, $r) = call('GET', 'products.php?q=a&sort=price_asc&limit=5');
$prices = array();
foreach ($r['data']['items'] as $p) {
    $prices[] = $p['price'];
}
$sorted = $prices;
sort($sorted);
check('products.php search sorted by price', $s === 200 && $prices === $sorted);

list($s, $r) = call('GET', 'product.php?id=999999');
check('unknown product gives 404', $s === 404 && $r['success'] === false);

list($s, $r) = call('GET', 'cart/list.php');
check('cart without token gives 401', $s === 401);

$email = 'test+' . time() . '@example.com';
list($s, $r) = call('POST', 'auth/register.php', array('name' => 'Smoke Test', 'email' => $email, 'phone' => '9876501234', 'password' => 'secret12'));
check('register a new customer', $s === 200 && strlen($r['data']['token']) === 64, json_encode($r));
$token = $r['data']['token'];

list($s, $r) = call('POST', 'auth/register.php', array('name' => 'Smoke Test', 'email' => $email, 'password' => 'secret12'));
check('same email again gives 409', $s === 409);

list($s, $r) = call('GET', 'auth/me.php');
check('me.php returns the new user', $s === 200 && $r['data']['email'] === $email);

list($s, $r) = call('POST', 'cart/add.php', array('product_id' => $productId, 'qty' => 1));
check('add to cart', $s === 200 && $r['data']['item_count'] === 1);

list($s, $r) = call('POST', 'cart/add.php', array('product_id' => $productId, 'qty' => 50));
check('too many gives 409', $s === 409);

list($s, $r) = call('POST', 'cart/update.php', array('product_id' => $productId, 'qty' => 2));
$cart = $r['data'];
check('update quantity to 2', $s === 200 && $cart['item_count'] === 2);
check('total = subtotal - discount + delivery', abs($cart['total'] - ($cart['subtotal'] - $cart['discount'] + $cart['delivery_fee'])) < 0.01);

list($s, $r) = call('POST', 'wishlist/toggle.php', array('product_id' => $productId));
check('wishlist toggle on', $s === 200 && $r['data']['in_wishlist'] === true);
list($s, $r) = call('POST', 'wishlist/toggle.php', array('product_id' => $productId));
check('wishlist toggle off', $s === 200 && $r['data']['in_wishlist'] === false);

list($s, $r) = call('POST', 'address/save.php', array('name' => 'Smoke Test', 'phone' => '12345', 'line1' => 'x', 'city' => 'y', 'state' => 'z', 'pincode' => '452001'));
check('bad phone gives 422', $s === 422);

list($s, $r) = call('POST', 'address/save.php', array('name' => 'Smoke Test', 'phone' => '9876501234', 'line1' => '1 Test Street', 'city' => 'Indore', 'state' => 'Madhya Pradesh', 'pincode' => '452001'));
check('save address (first one is default)', $s === 200 && $r['data']['is_default'] === true);
$addressId = $r['data']['id'];

list($s, $r) = call('POST', 'coupons/apply.php', array('code' => 'NO-SUCH-CODE'));
check('invalid coupon gives 422', $s === 422);

list($s, $r) = call('POST', 'orders/place.php', array('address_id' => $addressId, 'coupon' => ''));
check('place order', $s === 200 && strpos($r['data']['order_no'], 'KE') === 0, json_encode($r));
$orderId = $r['data']['order_id'];

list($s, $r) = call('GET', 'cart/list.php');
check('cart is empty after the order', $s === 200 && count($r['data']['items']) === 0);

list($s, $r) = call('GET', 'orders/detail.php?id=' . $orderId);
check('order detail has items and timeline', $s === 200 && count($r['data']['items']) === 1 && count($r['data']['timeline']) === 4);

list($s, $r) = call('POST', 'reviews/add.php', array('product_id' => $productId, 'rating' => 5, 'comment' => 'test'));
check('review before delivery gives 403', $s === 403);

list($s, $r) = call('POST', 'orders/cancel.php', array('id' => $orderId));
check('cancel order (stock goes back)', $s === 200 && $r['data']['status'] === 'cancelled');

list($s, $r) = call('POST', 'orders/cancel.php', array('id' => $orderId));
check('cancel again gives 409', $s === 409);

list($s, $r) = call('GET', 'orders/detail.php?id=1');
check('cannot open another user\'s order (404)', $s === 404 || ($s === 200 && $orderId === 1));

list($s, $r) = call('POST', 'auth/logout.php', array());
check('logout', $s === 200);
list($s, $r) = call('GET', 'auth/me.php');
check('old token no longer works', $s === 401);

echo "\n$passed passed, $failed failed\n";
exit($failed > 0 ? 1 : 0);
Terminal: php tests/smoke-test.php http://localhost:8000
KartEasy API test → http://127.0.0.1:8200/api/ PASS API is running and DB connected PASS home.php has banners, categories and products PASS products.php search sorted by price PASS unknown product gives 404 PASS cart without token gives 401 PASS register a new customer PASS same email again gives 409 PASS me.php returns the new user PASS add to cart PASS too many gives 409 PASS update quantity to 2 PASS total = subtotal - discount + delivery PASS wishlist toggle on PASS wishlist toggle off PASS bad phone gives 422 PASS save address (first one is default) PASS invalid coupon gives 422 PASS place order PASS cart is empty after the order PASS order detail has items and timeline PASS review before delivery gives 403 PASS cancel order (stock goes back) PASS cancel again gives 409 PASS cannot open another user's order (404) PASS logout PASS old token no longer works 26 passed, 0 failed
💡 Run it after every change

Changed a query? Run the smoke test. If something turns into FAIL, you know immediately which flow you broke — before your users do.

Extra hardening ideas

  • Login rate limit in the API: a login_attempts table (email, ip, time) and refuse after 5 failures in 15 minutes.
  • Email verification / OTP on register before the first order.
  • Refresh tokens: short-lived access tokens plus a long refresh token.
  • Daily database backup (Hostinger does this on most plans — check it is on).

Frequently Asked Questions

Is HTTPS really needed if the API checks everything?
Yes. Without HTTPS anyone on the same Wi-Fi can read the token and password. Hostinger and most hosts give free SSL; Android also blocks plain HTTP by default.
Can someone decompile my APK and attack the API?
They can read the API URL from any APK, so never put secrets in the app. KartEasy is safe because every rule (price, stock, ownership, delivery for reviews) is checked on the server.
Should I run the smoke test on the live site?
You can — it creates a separate test customer and cancels its own order, so stock returns. Delete the test users later in phpMyAdmin (email starts with test+).
← E-commerce App Tutorial
🔗

Share this topic with a friend

यह topic किसी दोस्त को भेजें

Found it useful? Send it to a classmate learning the same thing.

अच्छा लगा? जो दोस्त यही सीख रहा है, उसे भेज दीजिए।

Next topicApp Setup: Gradle, Packages & MVVM Your progress: 12 / 28 (43%) · Lesson 13 / 28 Continue →

How was this lesson?

Your feedback helps us make the content better.

Write a review or suggestion (optional)

After approval, your name and city may appear publicly with your review. Please do not write your email or phone number.

▶ CodingEasily on YouTube

Learn with video lessons in easy Hindi

Featured video: RDBMS Part 3 – Class XI Commerce IT

▶ Subscribe – it’s free