Gift Cards And Store Credit
Published
Store credit is money that lives in a row in your own database, which makes it the easiest money in a business to spend twice. The rules protecting it -- whether a PIN is demanded, whether the site's fraud team gets a veto, what the balance actually is -- are property names. We protected a file that configures a stored-value ledger, renamed the names a group at a time, and read how much credit each order collected.
The ledger under test
The configuration is a careful one. A PIN is required and the site supplies its own verifier that checks the digits. The site also supplies a fraud check that can veto a redemption -- a hot-card list, refreshed that morning. No order may drain more than two cards. Partial redemption is allowed, so a card can be spent down over several orders.
Unprotected, a 120 dollar redemption against a 500 dollar card applies and leaves 380. A wrong PIN is refused. A stolen card presented with its correct PIN is refused with blocked(reported-stolen-this-morning). A euro-denominated card against a dollar order is refused. A spent single-use card is refused. A third card on one order is refused. And an order asking 900 dollars off a 500 dollar card takes exactly 500 and no more.
A balance that cannot be read is not zero
Renaming balance produced the clearest single number in this pass. The ledger reports balance-was=undefined, and the clamp that limits a redemption to the value on the card is written the ordinary way: if the amount asked exceeds the balance, take the balance instead. 900 > undefined is false. The clamp never fires.
The order asking 900 dollars off a 500 dollar card was given 900 dollars of credit. Not zero, not an error -- the card became worth whatever the order asked for. The remaining balance printed as NaN, which is the only visible trace, and it printed into a field a reconciliation job would have to be specifically looking at to catch.
This is the same shape as a stock count that becomes NaN, and it is worth internalising as a general rule about renamed numeric fields: they do not become zero, they become uncomparable, and every threshold written as a refusal test then fails open.
The polarity of a returned flag decides open or closed
This file contains both shapes of guard answer, which is why it is the cleanest illustration in the series so far. The PIN verifier hands back ok -- a permission. The fraud check hands back blocked -- a prohibition. Both are written the way anyone would write them.
Renaming blocked redeemed the stolen card in full: 250 dollars, gone, on a card reported stolen that morning. The fraud check ran. It found the card on the hot list. It returned its refusal correctly. The ledger asked whether the answer was blocked, read undefined, and permitted. A missing prohibition permits.
Renaming ok did the opposite and refused every redemption including the legitimate one. And the log line it produced is the diagnostic worth remembering: pin-rejected(pin-matched). The verifier checked the digits, found them correct, and said so in the reason field; the ledger could not read the verdict, defaulted to refusing, and printed the verifier's own reason for accepting. A refusal that contains its own contradiction is the signature of a lost return-shape field, and this pass produced two of them independently -- the other read not-eligible(ok) in a coupon engine.
The lesson costs nothing at design time: write the flag your collaborator hands back as a permission, never as a prohibition.
Which record the field lives on decides whether it moves at all
The sample presents the same spent single-use card twice. Once as it came from the ledger, parsed from JSON, and once rebuilt in code the way an application hydrates a row into its own shape before handing it to a library.
Renaming usedAt moved only the rebuilt one. The card assembled in code lost its spent stamp and was redeemed a second time for 50 dollars; the parsed record was refused exactly as before, because keys inside parsed JSON are text and arrive spelled as they were written. The exposure is the record you assemble in memory and hand to an installed guard, not the row in your database.
That is the opposite of the intuition most people start with, including mine on the pass that first measured it. Persisted data is not the hazard here. The hazard is the object literal your own code builds a line before the call.
The rest, and what protection alone did
Renaming requirePin stopped demanding a PIN at all, so a card number off a receipt was enough to spend it. Renaming pinVerifier substituted the library's built-in, which checks presence rather than digits -- the reported verifier changes and every PIN passes. Renaming both is identical to renaming the flag alone, the same enabling-flag pattern as an entitlement check. Renaming fraudCheck silently replaced the site's hot-card list with a built-in that has no opinion. Renaming maxPerOrder let one order drain any number of cards, which is the exact pattern behind bundled-card fraud.
Protection alone changed nothing. Five profiles, byte-identical output, every redemption amount identical to the cent. Both control options -- currency and allowPartial, pinned to the library's own defaults -- measured inert, as predicted.
What to do about it
Keep renaming patterns off the ledger configuration, off the card records you build in code, and off the return shapes of your own guards. The third is the one that is genuinely easy to miss, because a field called ok or blocked looks like an implementation detail rather than a contract.
Then fix the two things that are worth fixing regardless of obfuscation. Require a balance to be a finite number before comparing it, and refuse the redemption otherwise -- that alone turns the 900-dollar payout into a loud refusal. And phrase every guard's answer as a permission, so a field that goes missing for any reason at all closes the door rather than opening it.
Finally, settle store credit server-side and reconcile the ledger against the orders. Every failure in this article is invisible to a client that only ever asks itself whether it was allowed.
Frequently asked questions
Does obfuscation break gift card redemption?
Not on its own. Protected on five profiles, the sample applied identical amounts to the cent and every refusal fired as configured. The failures in this article required member renaming aimed at the names the stored-value ledger reads.
What happens when a card balance field is renamed?
The card becomes worth whatever the order asks for. The clamp limiting a redemption to the balance is a comparison, and any comparison against an unreadable value is false, so an order asking 900 dollars off a 500 dollar card was given 900.
How did a card reported stolen get redeemed?
The fraud check returns a prohibition -- a blocked flag. Renaming that field meant the ledger read undefined and permitted, while the check itself ran correctly, matched the hot list and returned its refusal. A missing prohibition permits.
What does a lost return-shape field look like in a log?
As a refusal that contradicts itself. The measurement produced pin-rejected(pin-matched): the verifier confirmed the PIN and the ledger refused anyway, printing the verifier's own reason for accepting. If you see a line asserting both outcomes, look at renaming first.
Is our database at risk, or the objects we build in code?
The objects you build in code. A spent card parsed from stored JSON was still refused, because keys inside parsed JSON are text. The same card rebuilt in code lost its spent stamp and was redeemed a second time.
Which options here failed safely?
None failed loudly except the permission-shaped PIN verdict, which refused every redemption including the genuine one. That is the pattern across this area: stored value tends to fail open, which is why a finite-balance check and server-side settlement matter more here than almost anywhere else.
What is the single cheapest fix?
Require the balance to be finite before comparing it and refuse otherwise. It converts the worst result in this article into a loud refusal, and it protects you equally against a malformed row from an upstream service.
Related reading