COPPA §312.5 requires meaningful age verification — a gate that can be bypassed by opening a new incognito tab provides no protection. If age validation depends on a client-accessible cookie or localStorage value that the server trusts as proof of passing the gate, a child who gets blocked can clear browser storage and immediately re-register with a different birth date. The FTC expects age verification to be durable: the server must independently validate the submitted birth date on every account creation request and apply friction to repeated attempts.
Medium because the bypass requires deliberate action by the child rather than an automated exploit, but the attack is trivial for any motivated minor and renders the age gate legally meaningless.
Validate the submitted birth date server-side on every call — never read ageVerified or similar flags from the client. Add IP-based rate limiting to deter repeated retry attempts.
// app/api/auth/signup/route.ts
const attempts = new Map<string, { count: number; first: number }>()
const WINDOW_MS = 60 * 60 * 1000
const MAX = 5
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? 'unknown'
const now = Date.now()
const rec = attempts.get(ip)
if (rec && now - rec.first < WINDOW_MS && rec.count >= MAX) {
return Response.json({ error: 'Too many attempts.' }, { status: 429 })
}
attempts.set(ip, { count: (rec?.count ?? 0) + 1, first: rec?.first ?? now })
const { dateOfBirth } = await req.json()
// Always compute age from the submitted value — ignore any client flag
if (computeAge(new Date(dateOfBirth)) < 13) {
return Response.json({ error: 'Age requirement not met.' }, { status: 403 })
}
}
In production, replace the in-memory map with Redis or an equivalent distributed rate limiter.
ID: coppa-compliance.age-determination.age-gate-not-bypassable
Severity: medium
What to look for: Count all relevant instances and enumerate each. Evaluate the durability of the age gate enforcement. Client-side-only age gates are trivially bypassable: a user who is blocked can open an incognito window, clear cookies, or simply reload the page and enter a different birth date. Look for server-side mechanisms that make repeated bypass attempts harder: a session flag set before the age check result is returned, a rate limit on the registration endpoint by IP or device fingerprint, a temporary block cookie, or — for the strongest protection — a verified age token issued only after a positive age check that the account creation endpoint requires. Check whether the age gate uses localStorage or a regular (non-HttpOnly) cookie to store the result, which makes it trivially clearable. Look for whether the creation endpoint independently validates age on every request without relying on a prior client-side check.
Pass criteria: The age gate cannot be bypassed by refreshing the page, opening a new incognito window, or clearing browser storage, because age is validated server-side on every account creation request. The endpoint does not trust a "passed age gate" flag from the client — it validates the submitted birth date itself. There is reasonable friction (rate limiting, temporary blocks) to deter repeated retry attempts with different dates.
Fail criteria: Age check result is stored in a client-accessible cookie or localStorage that can be cleared or overwritten. The registration endpoint does not independently validate the birth date — it trusts a ageVerified: true flag sent from the client. No rate limiting or retry friction on the registration endpoint.
Skip (N/A) when: No age gate exists (in which case the age-gate-present check already fails).
Detail on fail: Specify the bypass vector. Example: "Age gate result stored in localStorage key 'ageVerified'. Clearing localStorage bypasses the gate. Server endpoint trusts this client-side value without re-validating the birth date." or "No rate limiting on /api/auth/signup. A user blocked for being under 13 can immediately retry with a different birth date from a new incognito tab.".
Remediation: Validate birth date server-side on every submission and add retry friction:
// Rate limiting example using a simple in-memory map
// (In production, use Redis or a distributed rate limiter)
const signupAttempts = new Map<string, { count: number; firstAttempt: number }>()
const WINDOW_MS = 60 * 60 * 1000 // 1 hour
const MAX_ATTEMPTS = 5
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? 'unknown'
const now = Date.now()
const record = signupAttempts.get(ip)
if (record && now - record.firstAttempt < WINDOW_MS && record.count >= MAX_ATTEMPTS) {
return Response.json(
{ error: 'Too many signup attempts. Please try again later.' },
{ status: 429 }
)
}
signupAttempts.set(ip, {
count: (record?.count ?? 0) + 1,
firstAttempt: record?.firstAttempt ?? now,
})
const { dateOfBirth } = await req.json()
// Always re-validate the submitted birth date — never trust a client-side flag
const age = computeAge(new Date(dateOfBirth))
if (age < 13) {
return Response.json({ error: 'Age requirement not met.' }, { status: 403 })
}
// ... account creation
}
Never read ageVerified, isAdult, or similar flags from the request body. Compute age from the submitted birth date on every call.