← Security
Technique Web and API trust boundaries

Integer and numeric bugs in security checks

A number can be well-formed and still violate the rule.

A customer’s export dashboard says its monthly quota grew after an export request failed. That is a symptom, not yet an explanation. We’ll trace the quantity from the request into the quota check, compare the state before and after, and find out whether a negative or imprecise number crossed a security boundary.

The skill to keep

Before changing a numeric check, name the allowed domain, inspect the value at each conversion, and verify the state that actually changed.

TypeScriptGo One export quota · a numeric boundary · TypeScript and Go
01 / Read the quota report

A counter moved the wrong way. The request path will tell us whether that matters.

At 09:20, a tenant with no export units left submitted a request for -1 rows. The endpoint returned an error from the export worker, but the quota ledger recorded a one-unit credit. A support screenshot alone cannot tell us whether the counter is authoritative, whether a later export used the credit, or whether the display is stale.

First compare the request ID, raw validated payload, quota ledger entry, counter snapshot, and worker result. Check whether a retry reused the same request ID. Reproduce only with a disposable tenant and a fake worker; do not send crafted quantities to a customer’s live account.

Case file / Data export APIA caller-controlled row count reaches a shared monthly quota.
Asset
Tenant export allowance and the data returned by the export worker.
Caller controls
The requested row count. Authentication identifies the tenant; it does not make the count trustworthy.
State
A remaining-unit counter and an export job record.
Invariant
Each accepted request reserves an integer quantity from 1 through 5,000 and cannot make remaining quota increase.
01 / SourceJSON quantity

Caller chooses the submitted value.

02 / ParseRuntime number

Parsing may reject, convert, or round.

03 / CheckQuota predicate

Does it define the full allowed range?

04 / EffectReservation + job

Confirm what persisted and what ran.

A numeric value has at least two contracts here: a representation contract (what values the language and parser can represent) and a business contract (what quantities this API permits). A check that handles only the upper bound can satisfy neither. The important question is not “is this a number?” but “does this exact value have the intended meaning at the state-changing boundary?”

Leave with: the caller-controlled quantity, the authoritative quota state, and the invariant you can measure.
02 / Trace the quantity

Follow the value across parsing, checking, storage, and work.

A dashboard may show remaining = 0, yet the handler, a queue message, and a database column can each hold a different representation. Trace the request ID through those boundaries. Record the exact value after parsing, the predicate result, the persisted delta, and the count given to the export worker. Avoid logging sensitive row contents; the quantity and identifiers are usually enough for this check.

Separate the observations from the hypothesesOpen after naming two plausible causes
Diagnostic checkpointChoose a check that distinguishes arithmetic from stale display.
Observation
The ledger contains a positive one-unit delta after the request for −1.
Possible causes
The handler negated an unchecked negative quantity; a retry credited twice; or the dashboard is showing a stale projection.
Discriminating check
For this request ID, compare raw parsed units, the exact ledger operation, committed counter value, and projection offset. Replay the request against a disposable fixture once.
Conclusion in this fixture
The handler accepts −1 because it checks only units > remaining; storage subtracts units, so the counter rises by one. This proves a quota accounting flaw in this path, not that an export occurred.

The negative value is a useful minimal reproduction because it separates the predicate from the storage operation. If a request for 1 is accepted at zero quota, investigate stale state, tenant selection, and transaction timing too. If only large values fail, inspect parsing, conversions, and arithmetic range. Do not assume every numeric anomaly is an overflow.

Before the repair: state which value the check saw, which state changed, and one competing explanation your evidence ruled out.
03 / Reproduce the change

The upper bound is present. The lower bound is missing.

The intentionally flawed functions below isolate the numeric predicate. They are examples for reading, not live endpoints. Their store interfaces leave persistence behind a boundary; the lesson’s demonstrated effect is what happens when a store interprets consume(key, units) as subtracting that quantity from the remaining counter.

Read the boundary in both languages.

The same scenario appears in TypeScript and Go; their number representations differ.

TypeScriptIntentionally flawed · isolated example
integer-limits.ts · missing lower bound
// Intentionally flawed: negative units pass the upper-bound check and add quota back.
export async function requestExportVulnerable(
	store: QuotaStore,
	keyID: string,
	units: number
): Promise<void> {
	const remaining = await store.remaining(keyID);
	if (units > remaining) throw new QuotaExceeded();
	await store.consume(keyID, units);
}
GoIntentionally flawed · isolated example
integer-limits.go · missing lower bound
// Intentionally flawed: a negative request passes and subtraction increases quota.
func RequestExportVulnerable(store QuotaStore, keyID string, units int64) error {
	remaining, err := store.Remaining(keyID)
	if err != nil {
		return err
	}
	if units > remaining {
		return ErrQuotaExceeded
	}
	return store.Consume(keyID, units)
}
04 / Bound the number

Validate the business range before reserving shared state.

The first repair is a complete range: a positive integer no greater than the product limit. TypeScript’s ordinary number is binary floating point, and integer values are only exactly distinguishable through ±(253−1). Number.isSafeInteger rejects fractions, infinities, and integers outside that exact range. If a JSON integer was already rounded during parsing, validate the original decimal string or use a parser that preserves it; checking the rounded number cannot reconstruct lost digits.

Go’s int64 exactly represents signed integer values in its fixed range, but arithmetic can overflow with deterministic wraparound semantics. A type does not establish the product’s maximum either. Here the explicit bound of 5,000 keeps this particular subtraction far from int64 limits; in other code, validate before adding or multiplying, or use checked operations when the business range can approach the representation boundary.

Compare the bounded implementations.

Both variants reject values outside the same API contract before touching quota state.

TypeScriptBounded request · illustrative store contract
integer-limits.ts · bounded request
const MAX_EXPORT_UNITS = 5_000;

export async function requestExportSafely(
	store: QuotaStore,
	keyID: string,
	units: number
): Promise<void> {
	if (!Number.isSafeInteger(units) || units < 1 || units > MAX_EXPORT_UNITS) {
		throw new InvalidUnits();
	}
	if (!(await store.reserve(keyID, units))) throw new QuotaExceeded();
}
GoBounded request · illustrative store contract
integer-limits.go · bounded request
const maxExportUnits int64 = 5_000

func RequestExportSafely(store QuotaStore, keyID string, units int64) error {
	if units < 1 || units > maxExportUnits {
		return ErrInvalidUnits
	}
	reserved, err := store.Reserve(keyID, units)
	if err != nil {
		return err
	}
	if !reserved {
		return ErrQuotaExceeded
	}
	return nil
}

For money, do not compare binary floating-point amounts with equality at an authorization threshold. Choose a defined currency precision, parse and validate it deliberately, and use integer minor units or a decimal type with explicit rounding rules. The rule must include edge cases such as refunds, negative adjustments, conversion, and rounding direction.

Leave with: the accepted domain, representation limits, and a state change that enforces the quota at its authoritative store.
05 / Verify the invariant

Test the boundary values and the state they can change.

A useful regression matrix includes -1, 0, 1, the exact product maximum, one above that maximum, a fraction, NaN or infinity where the boundary can receive them, and values at the language’s representation edge. Confirm that invalid inputs create no reservation and no job. For valid inputs, assert the committed remaining quota and the job quantity. In TypeScript, also test JSON text around Number.MAX_SAFE_INTEGER; in Go, test values around math.MaxInt64 if the API or arithmetic can reach them.

Then exercise the actual store implementation with a concurrent boundary case and a failed job insert. Those checks establish whether reservation and job creation are atomic for the deployed database. A unit test of the validator cannot establish persistence behavior.

Reject−1, 0, 1.5

Invalid sign, empty work, fractional unit.

Accept1…5,000

Valid integer quantity inside policy.

Reject5,001

First value outside the business maximum.

AssertQuota + job state

Check persisted effects, not just errors.

Read the isolated example filesFull context and boundaries
integer-limits.ts · full example
export class QuotaExceeded extends Error {}
export class InvalidUnits extends Error {}

export interface QuotaStore {
	remaining(keyID: string): Promise<number>;
	consume(keyID: string, units: number): Promise<void>;
	reserve(keyID: string, units: number): Promise<boolean>;
}

// Intentionally flawed: negative units pass the upper-bound check and add quota back.
export async function requestExportVulnerable(
	store: QuotaStore,
	keyID: string,
	units: number
): Promise<void> {
	const remaining = await store.remaining(keyID);
	if (units > remaining) throw new QuotaExceeded();
	await store.consume(keyID, units);
}

const MAX_EXPORT_UNITS = 5_000;

export async function requestExportSafely(
	store: QuotaStore,
	keyID: string,
	units: number
): Promise<void> {
	if (!Number.isSafeInteger(units) || units < 1 || units > MAX_EXPORT_UNITS) {
		throw new InvalidUnits();
	}
	if (!(await store.reserve(keyID, units))) throw new QuotaExceeded();
}
integer-limits.go · full example
package numericlimits

import "errors"

var ErrQuotaExceeded = errors.New("export quota exceeded")
var ErrInvalidUnits = errors.New("invalid export units")

type QuotaStore interface {
	Remaining(keyID string) (int64, error)
	Consume(keyID string, units int64) error
	Reserve(keyID string, units int64) (bool, error)
}

// Intentionally flawed: a negative request passes and subtraction increases quota.
func RequestExportVulnerable(store QuotaStore, keyID string, units int64) error {
	remaining, err := store.Remaining(keyID)
	if err != nil {
		return err
	}
	if units > remaining {
		return ErrQuotaExceeded
	}
	return store.Consume(keyID, units)
}


const maxExportUnits int64 = 5_000

func RequestExportSafely(store QuotaStore, keyID string, units int64) error {
	if units < 1 || units > maxExportUnits {
		return ErrInvalidUnits
	}
	reserved, err := store.Reserve(keyID, units)
	if err != nil {
		return err
	}
	if !reserved {
		return ErrQuotaExceeded
	}
	return nil
}

Leave with: evidence that invalid quantities do not alter quota, valid work stays within policy, and state transitions are atomic.
06 / Make the next call

Choose controls that match both the numeric type and the business rule.

Try a decision

A tenant with zero export units submits −1, then a very large integer. What do you change first?

The ledger shows a credit, while the worker reports no rows exported. You have the request ID and can reproduce against a disposable tenant. Which investigation and repair best preserves the contract?

Your next move

The confirmed example bug is narrow: an unchecked negative quantity passes an upper-bound-only predicate and can increase the counter when the store subtracts it. Further over-quota export depends on whether that counter is authoritative, whether credited quota is spendable, whether the job can be retried, and what the worker returns. Verify those links before estimating exposure.

For language behavior, consult MDN’s documentation for Number.isSafeInteger and Number.MAX_SAFE_INTEGER, and the Go language specification’s integer overflow rules. OWASP’s Input Validation Cheat Sheet discusses range and business validation. Checked 2026-10-01; these references describe platform and general validation behavior, not this fictional service’s actual exposure.

Connections to follow nextRelated lessons

The Math in Practice lessons Signed numbers, overflow, and fixed-width values and Floating point and rounding build the representation intuition used here. Race conditions and limit overruns covers the concurrent check-and-update problem that remains after a quantity is validated.

Question to keep: what exact values can reach this check, and what persisted fact proves each accepted value stayed inside the policy?