I shipped a production AI companion app as a solo developer. Before launch I audited my own code and found a hole I had walked straight past while building it: the chat API was trusting the client to tell it who the user was.
This is what I found, how I fixed it, and one deliberate weakness I chose to leave in place, because in this particular app, closing it would have hurt the people it exists for.
The bug: trusting the client for identity
The chat route accepted a POST body containing both the conversation and a userId. It used that ID to load the user’s stored memories and to write records back to the database.
export async function POST(request: NextRequest) {
const { messages, userId } = await request.json(); // identity from the client
const { data: memories } = await supabase
.from('memory')
.select('fact, feeling, thread')
.eq('user_id', userId); // trusted blindly
// ...build the prompt with those memories, call the model
}If you have built a Next.js app with a client-side Supabase session, you can probably see how this happens. The frontend already knows the user’s ID, and it is sitting right there in the session object. Sending it along with the request feels like passing a parameter, not like making a security decision. Everything works. Tests pass. The memories load. There is no error to alert you.
But userId arrived in the request body, and anything in a request body is under the caller’s control.
What an attacker could actually do
curl was all it took. Swap the ID and the endpoint would happily load someone else’s stored memories into the prompt. This is a companion app, so those memories are the most personal data in the system. Not usernames. Things people had said in hard moments.
Worse, the response streams back to the caller. The model would be reasoning over another person’s history, in text, to whoever asked. Writes had the same problem: records could be attributed to any account.
For an app whose entire premise is that people can say true things safely, this was not a minor bug. It was the thing the app promises not to do.
The fix: derive identity server-side from the token
The rule I settled on: identity is never an input. It is something the server derives. The client now sends its Supabase access token in the Authorization header, and the server verifies it.
/**
* Derive the user's identity from the Authorization: Bearer <token> header.
* Returns the verified user id, or null for anonymous visitors.
* The client-supplied body is never trusted for identity.
*/
async function getVerifiedUserId(
request: NextRequest,
admin: any
): Promise<string | null> {
if (!admin) return null;
const authHeader = request.headers.get('authorization') || '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
if (!token) return null;
try {
const { data, error } = await admin.auth.getUser(token);
if (error || !data?.user) return null;
return data.user.id;
} catch {
return null;
}
}In the handler, the body is now only ever read for content:
const { messages } = await request.json(); // no userId, ever
const userId = await getVerifiedUserId(request, supabaseAdmin);A forged token fails getUser(). A missing token returns null, which the app treats as an anonymous visitor, a first-class state here, since people can use the app without signing in.
That null return is worth dwelling on. It means the failure mode of the auth path is anonymous, not someone else. If verification breaks in a way I did not anticipate, the worst case is a user who loses their memories for a session, not a user who receives a stranger’s.
The part that made it worse: service role was already there
Here is the detail that turned a bug into a serious one, and the order it happened in matters.
The service-role client was not part of the fix. It had been added weeks earlier, for a good reason: memory rows are protected by row-level security scoped to the owning user, and the server needs to read those rows to build the prompt without acting as the user’s session. So it used a server-only client that bypasses RLS.
// Server-only. Bypasses RLS. Never prefix with NEXT_PUBLIC_, never commit.
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || '';
const supabaseAdmin = supabaseServiceKey
? createClient(supabaseUrl, supabaseServiceKey, {
auth: { persistSession: false, autoRefreshToken: false },
})
: null;That was a reasonable decision on its own. The problem is what it combined with.
The service-role key bypasses RLS entirely. The database will no longer stop a bad query. Every read has to be manually scoped, and that .eq('user_id', ...) becomes the only thing standing between one user and another.
Which is exactly why the identity bug mattered so much. RLS (the safety net that would normally have caught a wrong ID) had already been switched off for these queries. The single value holding the whole thing together was arriving in the request body, under the caller’s control.
Neither decision was catastrophic alone. A service-role client scoped by a verified ID is fine. A client-supplied ID against RLS-protected tables would have failed safely, because the database would have refused. Together they were a hole.
That is the part I would want a reviewer to look for: not one bad line, but two reasonable ones that removed each other’s protection.
const { data: allMemories } = await supabase
.from('memory')
.select('fact, feeling, thread, importance, sensitivity')
.eq('user_id', userId) // verified ID: the ONLY thing scoping this now
.order('importance', { ascending: false })
.limit(20);If I were starting again I would wrap these queries in a single helper that takes a verified ID and refuses to build a query without one, so the discipline lives in one place instead of at every call site.
Rate limiting in Postgres
With identity fixed, the next hole was cost. A public endpoint that calls a paid model API is a bill waiting to happen.
I put the limiter in the database as an RPC rather than in application memory, because serverless functions do not share state between calls, so an in-process counter would reset constantly and enforce nothing.
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| request.headers.get('x-real-ip')
|| 'unknown';
const identifier = userId ? `user:${userId}` : `ip:${ip}`;
const maxPerHour = userId ? MAX_MESSAGES_PER_HOUR_AUTH : MAX_MESSAGES_PER_HOUR_ANON;
const { data: allowed, error: rlError } = await supabaseAdmin.rpc('check_rate_limit', {
p_identifier: identifier,
p_max: maxPerHour,
p_window_seconds: 3600,
});Signed-in users are limited by user ID, anonymous visitors by IP. The limits are deliberately generous, because normal use of this app can involve a lot of short messages in a row, and I would rather absorb some abuse than interrupt someone mid-sentence.
It fails open. If the RPC errors, the request proceeds and the failure is logged loudly.
if (rlError) {
// Fail open (chat keeps working) but log loudly.
console.error('Rate limit check failed (failing open):', rlError.message);
}For most apps, failing closed is the correct instinct. Here it is not. A broken limiter costs me money; a limiter that blocks everyone when the migration has not run costs someone a conversation they may have needed. I made that trade knowingly, and the loud log is what keeps it from becoming a silent one.
The door I left open on purpose
This is the decision I would most want another engineer to argue with me about.
The app screens messages for signs of crisis before doing anything else. That screen runs before the rate limiter, and if it fires, the rate limiter is skipped entirely.
// Screen here so we can EXEMPT anyone in apparent crisis from the rate
// limiter. Someone in a hard moment should never hit a "take a rest" wall.
const keywordTier: CrisisTier = screenForCrisis(lastUserMessage);
const inApparentCrisis = keywordTier === 'tier1' || keywordTier === 'tier2';
if (supabaseAdmin && !inApparentCrisis) {
// ... rate limit check
}This is a bypass, and I know it. Anyone who works out the trigger conditions can send unlimited requests by including the right words.
I left it in anyway. The alternative is an app that tells someone in genuine distress to come back in an hour, and there is no cost saving that justifies that message arriving at that moment. The abuse ceiling is my API bill. The failure ceiling on the other side is a person, at 3am, being turned away by a rate limiter.
If abuse ever materialises, I would rather solve it with a separate, much higher ceiling on the exempt path than by removing the exemption.
Cost controls that are not rate limits
Two smaller things, both about bounding the cost of a single call rather than the number of calls.
History is trimmed on send, not on read. Only the last twelve messages go to the model, but the full array is still used for local logic, so first-message detection stays correct even though the model does not see everything.
const messagesForClaude = messages.slice(-HISTORY_MESSAGES_SENT);The static system prompt is cached, and kept byte-identical. Anything dynamic (memories, continuity notes) goes into a separate system block, so the large static block never changes and stays cacheable.
const systemBlocks = [
{
type: 'text',
text: SYSTEM_PROMPT,
cache_control: { type: 'ephemeral' }, // static -> cached
},
];
if (dynamicSystem) {
systemBlocks.push({ type: 'text', text: dynamicSystem });
}Concatenating the dynamic content into the main prompt would have broken the cache on every request. Splitting the blocks was a one-line change with a large recurring saving.
What I would do differently
Write the auth helper before the feature. The vulnerability existed because identity was convenient to pass and inconvenient to verify. Building getVerifiedUserId on day one would have made the insecure version the harder path.
Centralise the scoped queries. One helper that refuses to run without a verified ID beats remembering .eq('user_id', ...) forever.
Audit before launch, not after building. I found this by deliberately reading my own code as an attacker rather than as its author. That reading takes an hour and it was the highest-value hour I spent on the project.
Say the tradeoffs out loud in comments. Every deliberate weakness in this file has a comment explaining why it is there. Six months from now, some of those decisions will look like bugs to me. The comments are how I will know they were not.