Introduction
Gift cards and store credit represent a significant portion of e-commerce revenue, yet they introduce a unique set of security and accounting challenges that many teams underestimate. Understanding how to model them safely is critical: get it wrong, and you risk fraud, ledger discrepancies, and regulatory compliance issues. In this post, we'll walk through what developers should know about gift cards and store credit, focusing on practical patterns that have proven reliable in production across retail, SaaS, and marketplace platforms.
Unique, Hard-to-Guess Codes
The foundation of any gift card system is the code itself. This code must be both unique and cryptographically secure so that attackers cannot brute-force or predict valid codes.
Why this matters: A weak code scheme has been the root cause of several high-profile gift card frauds. If your codes follow a predictable pattern or use insufficient entropy, an attacker can generate batches of valid codes and drain balances across your entire system.
Use a cryptographically secure random number generator to produce codes with high entropy. A common approach is to generate 32 random bytes, then encode them in base32 or hex format to produce a human-readable string. Base32 is often preferable because it avoids confusing characters (no 0/O or 1/l).
import crypto from 'crypto';
function generateGiftCardCode(): string {
// Generate 32 bytes of random data (256 bits)
const randomBytes = crypto.randomBytes(32);
// Encode as base32 for readability
const code = randomBytes.toString('base64')
.replace(/[+/=]/g, (char) => {
if (char === '+') return '-';
if (char === '/') return '_';
return '';
})
.substring(0, 32);
return code;
}
// Validate the code format before lookup
function isValidCodeFormat(code: string): boolean {
// Must be alphanumeric, 32 characters, no special chars
return /^[A-Za-z0-9_-]{32}$/.test(code);
}Store the code in your database. If you want extra protection, hash the code before storage (similar to password hashing), then compare hashes at redemption time. This means even a database breach won't expose all valid codes.
import crypto from 'crypto';
function hashCode(code: string): string {
return crypto
.createHash('sha256')
.update(code)
.digest('hex');
}
// At creation:
const plainCode = generateGiftCardCode();
const hashedCode = hashCode(plainCode);
await db.giftCards.create({
id: uuidv4(),
hashedCode,
balance: 50.00,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
});
// At redemption:
async function lookupByCode(plainCode: string) {
const hashedCode = hashCode(plainCode);
return db.giftCards.findOne({ hashedCode });
}Always rate-limit code lookups and log failed attempts. A brute-force attack on even a strong code format can succeed if you allow unlimited guesses.
Ledger-Based Balances
Never store a gift card balance as a single value in your database. Instead, model it as a ledger of transactions. This approach gives you a full audit trail, prevents double-spending, and makes reconciliation straightforward.
Why this matters: If you store balance as a single number and a deduction fails partway through (e.g., inventory reserved but payment fails), you may incorrectly deduct the balance. A ledger approach makes it clear what happened and when.
Create a gift card and a corresponding ledger table:
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| hashedCode | String (SHA-256) | Unique, indexed |
| balance | Decimal | Denormalized for quick reads |
| createdAt | Timestamp | |
| expiresAt | Timestamp | NULL if no expiry |
The ledger table tracks every credit and debit:
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| giftCardId | UUID | Foreign key |
| amount | Decimal | Positive for credit, negative for debit |
| type | Enum | CREATION, REDEMPTION, REFUND, ADJUSTMENT |
| orderId | UUID | NULL for manual adjustments |
| createdAt | Timestamp |
Use transactions to ensure atomicity when redeeming:
async function redeemGiftCard(
giftCardId: string,
amountToRedeem: number,
orderId: string
): Promise<{ success: boolean; newBalance: number }> {
return db.transaction(async (trx) => {
// Lock the row to prevent concurrent redeems
const giftCard = await trx.giftCards
.where({ id: giftCardId })
.forUpdate()
.first();
if (!giftCard) {
throw new Error('Gift card not found');
}
if (giftCard.balance < amountToRedeem) {
throw new Error('Insufficient balance');
}
// Deduct the balance
const newBalance = giftCard.balance - amountToRedeem;
await trx.giftCards
.where({ id: giftCardId })
.update({ balance: newBalance });
// Log the ledger entry
await trx.giftCardLedger.insert({
id: uuidv4(),
giftCardId,
amount: -amountToRedeem,
type: 'REDEMPTION',
orderId,
createdAt: new Date(),
});
return { success: true, newBalance };
});
}By using a ledger, you can easily answer questions like "what happened to this gift card?" and "has this customer already redeemed this code?" A row-level lock (forUpdate) prevents race conditions during redemption.
Expiry Rules Where Allowed
Gift card expiry is heavily regulated in many jurisdictions. Before implementing expiry, understand your local laws: some regions forbid expiry entirely, others require it to be a minimum of five years, and some allow shorter terms if clearly disclosed.
Why this matters: Imposing an expiry date on customers in a jurisdiction that forbids it can result in fines, chargebacks, and regulatory action. Always consult legal or compliance before implementing expiry logic.
If expiry is allowed and desired, store an expiresAt timestamp on the gift card:
async function validateGiftCardNotExpired(giftCard: GiftCard): boolean {
if (!giftCard.expiresAt) {
// No expiry date set; card is valid indefinitely
return true;
}
return new Date() < new Date(giftCard.expiresAt);
}
async function redeemGiftCard(
giftCardId: string,
amountToRedeem: number,
orderId: string
): Promise<{ success: boolean; newBalance: number }> {
return db.transaction(async (trx) => {
const giftCard = await trx.giftCards
.where({ id: giftCardId })
.forUpdate()
.first();
if (!giftCard) {
throw new Error('Gift card not found');
}
// Check expiry *before* checking balance
const isNotExpired = await validateGiftCardNotExpired(giftCard);
if (!isNotExpired) {
throw new Error('Gift card has expired');
}
if (giftCard.balance < amountToRedeem) {
throw new Error('Insufficient balance');
}
// ... rest of redemption logic
});
}Many teams also add a "grace period" or allow customers to contact support to extend expiry. Track this separately with a expiryExtendedAt field if needed, and log the reason in a notes field for compliance audits.
Fraud Checks on Redemption
Gift cards are a vector for fraud, both from external attackers and dishonest insiders. Implement multiple layers of fraud detection when a code is redeemed.
Velocity checks: Flag unusually high redemption activity. If a single gift card is redeemed multiple times in quick succession or across many orders, that's suspicious.
async function checkGiftCardVelocity(
giftCardId: string
): Promise<{ isSuspicious: boolean; reason?: string }> {
const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
const recentRedemptions = await db.giftCardLedger
.where({ giftCardId, type: 'REDEMPTION' })
.where('createdAt', '>=', fiveMinutesAgo)
.count();
// Flag if more than 3 redemptions in 5 minutes
if (recentRedemptions > 3) {
return {
isSuspicious: true,
reason: `${recentRedemptions} redemptions in last 5 minutes`,
};
}
return { isSuspicious: false };
}Geographic checks: If you have location data from orders, compare the current redemption location to previous uses. A gift card used in New York and then California within minutes is suspicious.
Amount thresholds: Large partial redemptions can indicate testing before a full fraud attack. Log and review all redemptions over a certain threshold (e.g., $100).
Blacklist checks: Maintain a list of known fraudulent codes or patterns. If a gift card has been reported as stolen, block it immediately.
async function preRedemptionFraudCheck(
giftCardId: string,
orderId: string,
orderAmount: number
): Promise<{ allowed: boolean; reason?: string; riskLevel?: string }> {
const giftCard = await db.giftCards.findOne({ id: giftCardId });
if (!giftCard) {
return { allowed: false, reason: 'Gift card not found' };
}
// Check if blacklisted
const isBlacklisted = await db.giftCardBlacklist.findOne({ giftCardId });
if (isBlacklisted) {
return { allowed: false, reason: 'Gift card has been flagged' };
}
// Check velocity
const velocityCheck = await checkGiftCardVelocity(giftCardId);
if (velocityCheck.isSuspicious) {
return {
allowed: false,
reason: velocityCheck.reason,
riskLevel: 'HIGH',
};
}
// Additional checks...
return { allowed: true };
}Don't block the transaction outright on moderate fraud signals. Instead, log the event, flag it for review, and use a tiered response: warnings on first offense, manual review on second, and block on third.
Common Mistakes
Storing balance without a ledger: You lose the ability to audit and reconcile. Always use a ledger for financial data.
Weak code generation: Using sequential IDs, short strings, or predictable patterns exposes codes to brute-force attacks. Use 32+ bytes of cryptographic randomness.
No row-level locking during redemption: Concurrent redeems can result in double-spending if you don't lock the row. Always use forUpdate() or an equivalent mechanism.
Ignoring expiry regulations: Many jurisdictions have strict rules about gift card expiry. Violating them can result in legal liability. Consult compliance before implementing.
Insufficient fraud detection: Fraud on gift cards is common. Implement velocity checks, amount thresholds, and blacklist management from day one.
Not hashing codes: If your database is compromised, hashed codes are worthless to an attacker. Plain-text codes expose all valid codes immediately.
Key Takeaways
- Generate codes with high entropy (32+ bytes) using cryptographically secure randomness, and hash them before storage to limit exposure from database breaches.
- Model balances as ledgers, not single values, to ensure auditability and prevent double-spending through proper transaction handling and row-level locks.
- Understand and respect expiry regulations in your jurisdiction before implementing expiry logic; some regions forbid it entirely or require minimum validity periods.
- Implement layered fraud detection including velocity checks, blacklist management, geographic anomalies, and amount thresholds to catch suspicious patterns early.
- Always use database transactions with row-level locking (forUpdate) when redeeming to prevent race conditions and concurrent redemption of the same balance.






Comments
Loading comments…