Supabase Part 1: Securing Direct Database Queries
Software engineer with 15+ years of professional experience including SaaS, databases, system design, devops, and technical leadership.
Search for a command to run...
Software engineer with 15+ years of professional experience including SaaS, databases, system design, devops, and technical leadership.
No comments yet. Be the first to comment.
In this series, I share my solutions to common Supabase issues, particularly those related to its serverless nature and Postgres(t) backend. I also share tips and tricks to use it efficiently.
This is the second article in a series on Supabase, focusing on solutions to common problems, tips, and recommendations. In this post, I will share tips and tricks about role based access control, using row level security. Performance Using multiple ...
This is the third article in a series on Supabase, focusing on solutions to common problems, tips, and recommendations. In this post, I will share tips and tricks about edge functions compatible nodejs backend and developer productivity. Using Monore...
This is the third article in a series on Supabase, focusing on solutions to common problems, tips, and recommendations. In this post, I will share tips and tricks about multi tenancy and multi tenant user sessions. Database Model There are two common...
This is the second article in a series on Supabase, focusing on solutions to common problems, tips, and recommendations. In this post, I will share tips and tricks about role based access control, using row level security. Performance Using multiple ...
send http requests using SQL

This is the first article in a series on Supabase, focusing on solutions to common problems, tips, and recommendations.
When working with serverless platforms, it's common to use a custom backend alongside edge functions. Supabase is no exception. Unless you're building a small app or prototype, you'll likely run into issues sooner or later, whether due to scaling/performance limitations or missing features. Since the Supabase client relies on PostgREST techniques for authorization, you’ll need to implement a similar logic yourself to secure your queries.
If you’re using Kysely as your query builder (which is excellent, by the way), here’s a simplified method that does the job effectively:
// executteWithRLS.ts
import type { Kysely, Transaction, sql } from "kysely";
interface RLSData {
// set claims same as supabase client's and override if necessary
claims: Record<string, unknown>;
}
export async function executeWithRLS<T>(
kyselyClient: Kysely,
authData: RLSData,
callback: (trx: Transaction) => Promise<T>
) {
return await kyselyClient.transaction().execute(async(trx) => {
// set transaction level auth variables and run transaction
await sql`
SET LOCAL request.jwt.claims = ${sql.lit(JSON.stringify(authData.claims))};
SET LOCAL ROLE authenticated;
`.execute(trx);
return await callback(trx);
});
}
// example-service.ts
const db = DI.get("kyselyClient"); // somehow retrieve a singleton db client
const authData = DI.get("user"); // somehow retrieve user session from request (e.g. expressjs request.user)
const data = await executeWithRLS(db, authData, async(trx) => {
const newRecord = await trx.insertInto("...").values({}).execute();
const data = trx.selectFrom("...").where("...","=","...").execute();
return data;
});
While the example code is simplified, this is essentially how it works. Once you've validated the request with the Supabase access token, you can construct an authData variable with the required claims and call executeWithRLS whenever you need to query Postgres. This ensures that Row-Level Security (RLS) policies will function the same way as with PostgREST/Supabase client queries. Functions like auth.uid() will now retrieve user data from the claims you’ve set. It's important to note that SET LOCAL commands affect only the current transaction, which is exactly what you need. This also allows you to run admin queries that bypass policies using the same database client/connection without risking data leaks.
You might hear some people recommending different approaches, like setting connection-level parameters or issuing SET commands for each connection and reserving them per user. These are poor practices for both security and performance. Direct PostgreSQL connections are expensive and should be pooled for all clients. Supabase already handles this automatically with their pooler called "Supavisor" which has two modes: session and transaction (the default). When connecting to your database outside the Supabase client, you have three options.
If you choose the first two options (session mode or direct connections), you’ll quickly exhaust your connection limit, which will degrade application performance.
If you use transaction mode (recommended for scaling), you'll introduce data races and security vulnerabilities. Users may inadvertently access each other’s data because transaction mode runs multiple queries concurrently on the same pooled connections. Any connection-level changes will affect all queries sharing that connection. This is why transaction-level roles/claims are important for ensuring both safety and efficiency.
SET LOCAL statement_timeout = '10s' to adjust Postgres' maximum execution time on a per-query basis. I recommend setting a small timeout by default to prevent the database from becoming overloaded by faulty or malicious queries.login_as_user in the "auth" schema. While it may seem tempting to use it for authorization, it's important to understand that this is not its intended purpose. The function is designed solely for policy testing. In fact, Supabase dashboard uses it for user impersonation within the SQL editor. It suffers from the same issues mentioned earlier, so it must be avoided.When using a custom backend alongside edge functions and client-side queries, always double-check that everything you're doing is secure on all fronts. Running queries with the correct roles and privileges is just as important as securing them with RLS policies. Also, ensure that you fully understand the connection pooling modes.
Note that everything described in this article also applies to edge functions if you use a database client within them. While this setup is very useful, be aware that you can’t restrict database access to whitelisted IP addresses in this case, as edge functions have dynamic IPs. Querying with the Supabase JS client is not an issue, because it bypasses IP restrictions.
In the next article, I’ll discuss how to write efficient RLS policies.