Protecting gift cards, vouchers and coupons
A code that is worth something when it is accepted will be guessed. OWASP calls it token cracking (OAT-002), and it comes in two shapes:
- Guessing codes. Gift card numbers are often sequential, or share a known prefix; vouchers and invitation codes come from a small alphabet. A script walks the space against your balance check or redemption form and keeps whatever has money on it — hundreds or thousands of codes from one client, nearly all refused. The balances are spent or sold before their owners notice.
- Cracking a PIN. The attacker has a gift card's number — photographed in a shop, read off a receipt — and guesses the PIN beside it, one refusal at a time.
Each attempt looks like a shopper typing a code. Across attempts they do not,
which is what an assessment with a code
sees.
What is being watched
- Many codes from one place. A shopper redeems a gift card or two and tries
a coupon, perhaps another when the first has expired. So does a coupon
extension — Honey and its like — trying a few dozen codes it knows for your
shop from the shopper's own browser, nearly all refused. Fifty distinct codes
in ten minutes is a reason (
velocity:codesFromAddress, 30) and a hundred and fifty a challenge on its own (60) — across an IPv6 source's /48 at sixteen times those (velocity:codesFromNetwork), unless its devices make it a crowd's. Whoever passed a challenge is not asked again for these (the reasons are given at no points). - From one device, whatever the address. Guessing through a proxy pool is
one address per code or two, and one browser for all of them. When the
attempts carry the visitor's receipt (send the
_abidcookie assessionToken), the device is counted the same way (velocity:codesFromDevice), and a list seen both ways counts once. - Refusals. Guessing is refused almost every time. Fifty refusals with
hardly an acceptance beside them from an address, or fifty from one device
whatever it was accepted for (
velocity:invalidCodes), weigh 40, a hundred and fifty 60. - One code, refused again and again. A gift card's owner mistypes its PIN
once or twice; five refusals in ten minutes, at least two from each address
that tried it, is the PIN being cracked (
velocity:codeCracking, 40). An expired promo code everybody tries once is refused once per shopper, and suspects nobody. A cracker rotating a new address for every guess is refused once per address too, which is why the editor's Stop gift card guessing, held to your redemption path, counts three refusals of one card from anywhere. - The client, as for every assessment.
A shared promo code is one code for everybody: how many addresses tried a code
is reported (addressesForCode) and never weighed.
The thresholds leave coupon extensions alone, because many shops are glad of
them. A gift card has no extension trying it on anyone's behalf, so its
redemption can be held much tighter: the rule editor's Stop gift card
guessing blocks ten refusals from an address or a device, or three of one
card, on the path you give it — from a crowd's address (ipInfo.crowd: a VPN's
exit, a carrier's) only at thirty, where ten can be everyone's typos.
What to send as code
The code as the visitor typed it — a gift card's number (without its PIN), a
voucher, a coupon, an invitation or referral code. Case, spaces and dashes are
ignored, so abcd-efgh and ABCDEFGH are one code.
It is never stored. What is kept is a 24-bit keyed digest, for an hour: enough to tell an hour's codes apart, and too short to give one back — even to someone holding the database and its key, a 16-digit card number with a known six-digit prefix has some six hundred candidates behind each digest. Codes from a small space (a short all-digit code, under about a billion possibilities) are narrowed to a few that way; if yours are like that, send your own keyed hash of the code instead — the same value every time for the same code.
Never send a payment card's number as a code, a prepaid one included. Payment
cards go in card, by your processor's fingerprint.
At the edge, no code
With the edge enforcer in front of the site, name the redemption endpoint and the field that carries the code:
"ANTIBOT_CODE_FIELDS": "/gift-card/redeem=card"
Every POST to it is assessed with the code — never the PIN beside it — and
your site's answer is reported as accepted or refused: a 404 or a 422 for an
unknown card is a refusal, a 200 an acceptance, or your application says so
itself with an antibot-code: success|failure response header. See
first-party for the details.
From your server
verdict = antibot.assess(
ip=client_ip,
headers=raw_header_pairs,
method="POST",
url="https://shop.example/gift-card/redeem",
code=form["card_number"],
session_token=request.cookies.get("_abid"), # the device, where the agent ran
)
if verdict["action"] == "block":
abort(403)
if verdict["action"] == "challenge":
require_captcha()
balance = gift_cards.check(form["card_number"], form["pin"])
antibot.report_code_outcome(
ip=client_ip,
code=form["card_number"],
outcome="success" if balance else "failure",
session_token=request.cookies.get("_abid", ""), # whose attempt it was: "" for none
)
The same in every server SDK: assess with code, and
reportCodeOutcome. Report every check, accepted or refused: the refusals are
what tell guessing from a shopper.
The response carries the counts:
"code": {
"codesFromAddress": 41, "codesFromDevice": 0, "addressesForCode": 1,
"failuresFromAddress": 40, "successesFromAddress": 0,
"failuresFromDevice": 0, "failuresForCode": 1
}
and rules can read every one of them as code.* — for example to
hold a balance-check page tighter than redemption:
path == "/gift-card/balance" and code.failuresFromAddress >= 5
Knowing it is happening
Edge traffic counts the reasons like any other, and the protection checklist on the Proxy page says whether redemptions are being assessed and their refusals reported.