Dev.to Security 🔐 Cybersecurity 👁 0 📖 6 min read

Nothing in our code creates an anonymous session, and eleven route handlers have five different answers for one

Munchable is a gut-condition barcode scanner whose server deliberately holds almost nothing: an email address, a subscription record, and any product labels you contributed. The policy is at munchable.app/privacy. Every

Munchable is a gut-condition barcode scanner whose server deliberately holds almost nothing: an email address, a subscription record, and any product labels you contributed. The policy is at munchable.app/privacy. Every one of its 29 route handlers starts the same way.

const user = await getUser(request);
if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });

Eleven of them then do something else, and the something else is the subject of this post:

if (user.isAnonymous) {
  return NextResponse.json({ error: 'account_required' }, { status: 403 });
}

Twenty-one references to isAnonymous across thirteen files. And then:

$ grep -rn "signInAnonymously" apps packages
$ echo $?
1

Nothing in this repository creates the session all of that is about.

What the flag is

Supabase supports anonymous sign-in: the client asks for a session without credentials and gets a real, signed JWT with a real user id and is_anonymous: true in its claims. It is a genuine session, it works against row level security, and it costs the holder nothing: no email, no confirmation, no limit beyond rate limiting. You can have a thousand.

Whether it is possible at all is a project setting in the Supabase dashboard. Not a dependency, not a config file in the repo, not something a code review would see change.

Our clients do not call it. The app signs you in with an email link or a provider, and that is the only way a session is minted.

The cost of knowing anyway is one field. Sessions are verified locally against the JWKS rather than by asking the Auth server on every request, and the parser lifts the claim out while it is already there:

isAnonymous: claims.is_anonymous === true,

No round trip, no query. The expensive part of the check is not the check.

Five answers to one condition

Grouping the eleven routes by what they actually do was the part that surprised me. There are five behaviours, and I did not choose between them deliberately.

1. A flat 401. Account deletion, data export, recording consent, listing your own support tickets, reading one and replying to one all answer unauthorized. These are operations on a person, and an anonymous session is not a person: there is nothing to delete, nothing to export, no address to write a consent record against.

2. A 403 with its own error code. The contributor-standing read and the ingredient resolver answer account_required. The distinction matters to a client: 401 means "sign in", 403 means "you are signed in and this needs a real account", which are two different pieces of UI. That second branch exists only because anonymous sessions exist. Delete the concept and the 403 becomes unreachable.

3. A benign 200. The consent check is a question the app asks on launch: have the terms changed since you accepted them?

if (!user || user.isAnonymous) return NextResponse.json({ needsReconsent: false });

No error at all, because the honest answer for a session with no consent row is "you do not need to re-accept anything", and an error here would put a failed request in front of somebody's first screen for no reason.

4. Silent degradation. Three paths do the work and withhold the money. Checkout skips the reward-discount lookup entirely:

const reward = user.isAnonymous ? null : await rewardDiscount(user.id);

A label contribution from an anonymous caller is stored and simply credits nobody. And the label-reading route withholds the step that costs us a model call, while still answering with everything the deterministic path produced. In all three, the caller gets the product feature and not the payout.

5. Picking which email to trust. Opening a support ticket is the one place the flag is not a gate. Anyone can open a ticket, signed in or not, because a person whose app will not start is in no position to prove who they are. The flag decides whose address the conversation is attached to:

const signedIn = Boolean(user && !user.isAnonymous);
const email = signedIn && user?.email ? user.email.toLowerCase() : suppliedEmail;

A real account's address is verified and cannot be typed wrong, so it wins. An anonymous session's claim about an email is worth exactly as much as a stranger's, which is why it is treated as one.

There is a sixth answer, and it is not in a route at all. The rewards query excludes anonymous contributors in SQL:

where u.id = r.contributor_id and u.is_anonymous = true

That is the one I would keep if I could keep only one. A route check protects the callers that go through that route. A condition in the settlement query protects the money from every caller that ever will.

Why it was written for a feature that is off

The threat model is the rewards programme. Contributors who photograph a label we do not have can earn credit against their subscription, and the gate for that is in an earlier post if you want the mechanics. Credit is money. An identity that is free to mint and carries no verified address, earning money, is not a feature with an abuse problem. It is a faucet.

The same reasoning covers per-account budgets. A daily cap keyed to an account id means nothing when account ids are free, so any budget guarded only by "is there a session" is not a budget.

Which gives the rule the eleven routes follow, even though I never wrote it down until now: anything that grants money, spends money, or acts on a person requires a verified address. Everything else may take any session at all.

The endpoint where anonymous is welcome

That last sentence has one deliberate instance, and I like it more than any of the refusals.

The taxonomy endpoint serves the app's ingredient vocabulary, so that corrections reach a phone without an app release. It takes any session, including an anonymous one, and the client-side comment says why:

/**
 * "Signed in" for taxonomy purposes is ANY Supabase session, the device-scoped
 * anonymous one included: every device should receive ingredient updates.
 */

A device that cannot identify itself is not a reason to let its knowledge of ingredients go stale. The response contains nothing about anybody, so there is nothing to protect, and the worst case for an unidentified caller is that they get a correct answer about food.

If the whole repository only distinguished "session" from "no session", that endpoint and the deletion endpoint would be in the same category, which is obviously wrong.

Dead code, or insurance?

The case for deleting all of it: 21 branches guarding a feature nobody enabled, no end-to-end test can reach them, and the only test that touches the concept checks that the claims parser reads the boolean. Untested branches that cannot be triggered are the textbook definition of code that rots.

The case for keeping it, which is the one I believe: the switch is not in this repository. It is a toggle in a dashboard, and the list of people who can flip it is longer than the list of people who review this code. A posture that depends on a setting in somebody else's UI staying off is not a posture. Guarding in code means the toggle is a toggle rather than a vulnerability.

What I would change is the shape, not the presence. Eleven files repeating the same two lines is eleven chances to forget, and forgetting fails open: a new route that checks only !user is a route that accepts a free identity. The fix is a wrapper, requireAccount(request), returning either a verified account or the 403, so that the safe thing is the short thing to type. Right now the safe thing is two lines longer than the unsafe thing, which is backwards, and the only reason it has held is that there have been eleven routes rather than fifty.

If you want to see the half of the product that asks nothing of you at all, it is the public side: munchable.app/answers indexes a page per ingredient question, and munchable.app/conditions has a guide per condition. No session of any kind required, anonymous or otherwise.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.