START HERE·PRACTICE TEMPLATES·Verified June 2026 · Lua 5.4 · ox_lib 3.x
Learning with an AI assistant?
Copies this whole lesson - every step, code block, and the exact console errors - plus 2026 ground rules (no lua54 'yes', Cfx.re Portal, correct callback signatures) as a ready-to-paste mentor prompt.
Start Here · Practice Templates

Shop with server validation (advanced)

This is not a tutorial you copy. It is a half-finished resource called qu_shop with a real security hole in it. The client tells the server which item to buy and how much it costs, and the server believes every word. Your job is to close that hole so a buy only goes through when the server itself confirms the item is real, knows its true price, and sees that the player can actually pay. You write the missing logic; a hardened solution is one click away when you want to check yourself.

Goal
A buy action that cannot be cheated: the server checks the item exists, the price, and that the player can afford it before giving anything.
Difficulty
Advanced
Estimated time
~30 minutes
Skills practiced
events, server authority, validation, config tables, the never-trust-the-client rule

Watch

A buy flow built with server-side validation.
BEFORE YOU START

The goal

You are hardening a small shop resource named qu_shop. It has three moving parts:

  • A config table, Config.Items, that maps an item name to its price. This table lives on the server and is the single source of truth for what exists and what it costs.
  • A client script that asks to buy an item by triggering a server event.
  • A server script that receives that request and is supposed to hand over the item.

Right now the shop is wide open. By the end you want this exact behaviour:

  • Buying a valid item the player can afford works: money comes off, the item goes on.
  • Buying an item that is not in Config.Items is rejected.
  • Buying an item the player cannot afford is rejected.
  • Sending a fake price from the client changes nothing, because the server never reads the client's price.

Vocabulary

Server authority
The rule that the server, not the player's game, has the final say on anything that matters: money, items, position. The client can ask, but the server decides.
Client-sent value
Any piece of data that arrives from a player's machine. A cheater can change it to anything before it is sent, so it can never be trusted on its own.
Config table
A plain Lua table you define on the server that holds settings or data, here the list of items and their prices. It cannot be edited by a player because it never leaves the server.

The insecure starting point

Below is the resource as it ships, broken on purpose. Read every comment. The vulnerability is not hidden, it is written right into the code so you can see exactly what a cheater would exploit.

The manifest. Note there is no lua54 line: Lua 5.4 is the only runtime now, so that old setting is gone.

lua
-- fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
 
shared_script 'config.lua'
client_script 'client.lua'
server_script 'server.lua'

The config. This table lives on the server and is the only honest record of what exists and what it costs.

lua
-- config.lua
Config = {}
 
-- item name  ->  price
Config.Items = {
    water = 10,
    bread = 15,
    phone = 250,
}

The client. This is where the danger starts. The client decides the item name and the price, then ships both to the server.

lua
-- client.lua
-- A real shop UI would call this when a button is clicked.
-- For the exercise we expose it as a command so you can test by typing.
RegisterCommand('buy', function(_, args)
    local itemName = args[1]                 -- e.g. "phone"
    local price = tonumber(args[2]) or 0     -- DANGER: the client picks the price
 
    -- The client sends BOTH the item and a price it made up.
    -- A cheater could send buy phone 0 and pay nothing.
    TriggerServerEvent('qu_shop:buyItem', itemName, price)
end, false)

The server. This is the part you will rewrite. Right now it trusts the client completely.

lua
-- server.lua
 
-- Placeholder money/item functions.
-- A real server would use a framework (ESX, QBCore, Qbox) or oxmysql here.
-- For the exercise these are stand-ins so you can focus on the validation.
local function GetMoney(src)
    -- Pretend every player starts with 100.
    return 100
end
 
local function RemoveMoney(src, amount)
    print(('[qu_shop] removed %d from player %d'):format(amount, src))
end
 
local function GiveItem(src, name)
    print(('[qu_shop] gave %s to player %d'):format(name, src))
end
 
RegisterNetEvent('qu_shop:buyItem', function(itemName, price)
    local src = source -- the real player id, set by the server, not the client
 
    -- TODO: this handler trusts EVERYTHING the client sent.
    -- TODO: it never checks that itemName is a real item.
    -- TODO: it uses the client's price instead of the server's price.
    -- TODO: it never checks the player can afford it.
 
    RemoveMoney(src, price)   -- charges whatever the client claimed (maybe 0)
    GiveItem(src, itemName)   -- hands over whatever the client named
    print(('[qu_shop] %d bought %s for %d'):format(src, itemName, price))
end)

Your job

Rewrite server.lua so the buy can only succeed when the server agrees. Work through this checklist:

  • Never trust the price or money the client sent. The price argument is poison. Stop using it.
  • Look the price up from Config.Items on the server. The true price is Config.Items[itemName], computed server-side, every time.
  • Reject unknown items. If Config.Items[itemName] is nil, the item does not exist. Return early and give nothing.
  • Check affordability against the server-known price. Compare the player's money to the price the server looked up, not the one the client sent.
  • Only then remove money and give the item. Charge the real price, then hand over the item. Order matters: validate first, act last.
  • Add a basic spam guard (optional but encouraged). Track the last buy time per player and ignore requests that arrive too fast, so nobody can hammer the event.

One change makes the client side safer too: the client should send only the item name, nothing else. There is no reason for it to send a price, because the server will never read one. Trim that argument out.

Hints

If you are stuck, read these in order
  • The single trustworthy input is the item name. Treat it as a request, not a fact. Re-look it up: local price = Config.Items[itemName].
  • That one line replaces the client's price entirely. If price is nil, the item is not real, so return immediately and do nothing else.
  • Return early on every failed check. Each guard is if (bad) then return end near the top of the handler. By the time you reach the money line, everything left is valid.
  • source inside a RegisterNetEvent handler is the real player id, filled in by the server. It is the one value the client cannot fake, which is why you use it for money and item lookups instead of any id the client might send.
  • For the spam guard, keep a table like local lastBuy = {} outside the handler, store lastBuy[src] = os.time() after a successful buy, and reject if the last buy was under a second ago.

Reveal the solution

Stuck or done? Reveal a hardened solution

Here is the corrected resource. The client now sends only the item name, and the server validates existence, price, and affordability before it touches money or items.

The client, trimmed. It sends the item name and nothing else.

lua
-- client.lua (hardened)
RegisterCommand('buy', function(_, args)
    local itemName = args[1] -- the ONLY thing we send
    if not itemName then
        print('[qu_shop] usage: /buy <item>')
        return
    end
 
    -- No price. The server already knows the price.
    TriggerServerEvent('qu_shop:buyItem', itemName)
end, false)

The server, hardened. Every check returns early, and the price is always read from Config.Items.

lua
-- server.lua (hardened)
local function GetMoney(src)
    return 100 -- placeholder; a real server reads the player's balance
end
 
local function RemoveMoney(src, amount)
    print(('[qu_shop] removed %d from player %d'):format(amount, src))
end
 
local function GiveItem(src, name)
    print(('[qu_shop] gave %s to player %d'):format(name, src))
end
 
-- Spam guard: remember the last buy time per player.
local lastBuy = {}
 
RegisterNetEvent('qu_shop:buyItem', function(itemName)
    local src = source -- real player id, set by the server
 
    -- 1. Basic input shape. The name must be a string.
    if type(itemName) ~= 'string' then
        print(('[qu_shop] %d sent a non-string item, rejected'):format(src))
        return
    end
 
    -- 2. Spam guard. Ignore requests that arrive faster than once per second.
    local now = os.time()
    if lastBuy[src] and (now - lastBuy[src]) < 1 then
        print(('[qu_shop] %d is buying too fast, rejected'):format(src))
        return
    end
 
    -- 3. Look the price up on the SERVER. This replaces anything the client claimed.
    local price = Config.Items[itemName]
 
    -- 4. Reject unknown items. nil means it is not in the config.
    if price == nil then
        print(('[qu_shop] %d tried to buy unknown item %s, rejected'):format(src, itemName))
        return
    end
 
    -- 5. Check affordability against the server-known price.
    local money = GetMoney(src)
    if money < price then
        print(('[qu_shop] %d cannot afford %s (%d), rejected'):format(src, itemName, price))
        return
    end
 
    -- 6. Everything checked out. Charge the real price, then give the item.
    RemoveMoney(src, price)
    GiveItem(src, itemName)
    lastBuy[src] = now
    print(('[qu_shop] %d bought %s for %d'):format(src, itemName, price))
end)

Why each check closes the exploit:

  • Reading Config.Items[itemName] on the server is the whole fix. The client's price argument is gone, so buy phone 0 cannot make phone free. The server always charges what its own table says.
  • The nil check stops a cheater from inventing items. If the name is not a key in Config.Items, there is no price, so the buy dies before any item is given.
  • The affordability check uses the price the server looked up, not one the client sent, so a player can never claim an item costs less than they have.
  • The spam guard stops a player firing the event in a tight loop to abuse any side effect of a buy.
  • Returning early means by the time you reach RemoveMoney, every condition is already true. The dangerous lines run only on a fully validated request.

What you practiced

  • Spotted a client-sent value (the price) and removed it as a source of truth.
  • Looked the price up from a server-side Config table so the server, not the player, decides the cost.
  • Rejected unknown items by checking for a nil price before acting.
  • Checked affordability against the server-known price, not the client's claim.
  • Used return-early guards so money and items only change on a fully validated request.
  • Added a basic per-player spam guard with os.time.
  • Internalised the rule: trust only which item the player wants, and re-look-up even that.

Nicely done. You just wrote the exact validation layer that separates a shop that gets drained in a day from one that holds up. Every paid action you build from here, garages, jobs, banks, follows this same shape: the client asks, the server checks, the server decides. You are ready for Track B, where you build real shops backed by a framework and oxmysql instead of placeholder functions. Go open Track B and turn this pattern into a production resource.