Auth and Row Level Security
In the JWT doc, we hashed passwords with bcrypt and signed tokens ourselves. Supabase Auth does that part for us: it stores users, hashes passwords, and issues JWTs. Our Express server only needs to:
- Forward sign up and login requests to Supabase
- Verify the token on protected routes
- Make sure each user can only touch their own data
Sign Up and Login
Auth methods like signInWithPassword() store the logged-in session inside the client object. If you call them on a shared client, the next request from a different user would run with the first user's session. Always create a fresh client for auth calls on a server.
import "dotenv/config";
import { createClient } from "@supabase/supabase-js";
const { SUPABASE_URL, SUPABASE_SECRET_KEY, SUPABASE_PUBLISHABLE_KEY } = process.env;
if (!SUPABASE_URL || !SUPABASE_SECRET_KEY || !SUPABASE_PUBLISHABLE_KEY) {
throw new Error("SUPABASE_URL, SUPABASE_SECRET_KEY and SUPABASE_PUBLISHABLE_KEY must be set");
}
const serverOptions = { auth: { persistSession: false, autoRefreshToken: false } };
// Bypasses RLS. Use only for trusted server-side work.
export const supabaseAdmin = createClient(SUPABASE_URL, SUPABASE_SECRET_KEY, serverOptions);
// A fresh client for auth calls, so sessions never leak between requests
export function createAuthClient() {
return createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, serverOptions);
}
// Acts as the given user, so RLS policies apply to every query
export function createUserClient(accessToken) {
return createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
...serverOptions,
global: { headers: { Authorization: `Bearer ${accessToken}` } },
});
}
import { Router } from "express";
import { createAuthClient } from "../config/supabase.js";
const router = Router();
router.post("/register", async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: "email and password are required" });
}
const { data, error } = await createAuthClient().auth.signUp({ email, password });
if (error) return res.status(400).json({ message: error.message });
res.status(201).json({ id: data.user.id, email: data.user.email });
});
router.post("/login", async (req, res) => {
const { email, password } = req.body;
const { data, error } = await createAuthClient().auth.signInWithPassword({ email, password });
// Same message for wrong email and wrong password, so attackers cannot discover which emails exist
if (error) return res.status(401).json({ message: "Invalid email or password" });
res.json({
access_token: data.session.access_token,
refresh_token: data.session.refresh_token,
expires_at: data.session.expires_at,
});
});
export default router;
New projects require users to confirm their email before they can log in. While developing, you can turn this off under Authentication > Sign In / Providers > Email > Confirm email.
Protecting Routes with Middleware
The client sends the token as Authorization: Bearer <access_token>. The middleware asks Supabase whether the token is valid and who it belongs to:
import { supabaseAdmin } from "../config/supabase.js";
export async function requireAuth(req, res, next) {
const [scheme, token] = (req.headers.authorization || "").split(" ");
if (scheme !== "Bearer" || !token) {
return res.status(401).json({ message: "Missing bearer token" });
}
const { data, error } = await supabaseAdmin.auth.getUser(token);
if (error || !data.user) {
return res.status(401).json({ message: "Invalid or expired token" });
}
req.user = data.user;
req.accessToken = token;
next();
}
getUser(token) sends the token to Supabase Auth, which checks the signature, the expiry, and that the user still exists. It costs one network request per call.
getClaims()If your project uses the new asymmetric JWT signing keys, supabaseAdmin.auth.getClaims(token) verifies the token locally using Supabase's public keys, with no network request after the first one. It returns data.claims, where claims.sub is the user ID. The trade-off: it cannot tell if the user was deleted or banned since the token was issued, so keep token lifetimes short.
Row Level Security
Let's give every user their own private notes. Run this in the SQL Editor:
create table notes (
id bigint generated always as identity primary key,
user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
title text not null,
body text,
created_at timestamptz not null default now()
);
alter table notes enable row level security;
create policy "Users can read their own notes"
on notes for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Users can create their own notes"
on notes for insert
to authenticated
with check ((select auth.uid()) = user_id);
create policy "Users can update their own notes"
on notes for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "Users can delete their own notes"
on notes for delete
to authenticated
using ((select auth.uid()) = user_id);
auth.uid() returns the ID of the user from the JWT that came with the request. With these policies:
selectonly returns rows whereuser_idmatches the callerinsertrejects rows whoseuser_idis someone else's (anddefault auth.uid()fills it in for us)updateanddeletesilently skip rows the caller does not own
Wrapping it as (select auth.uid()) lets Postgres evaluate it once per query instead of once per row, which matters on large tables.
Using RLS from Express
The key idea: for user data, query with createUserClient(req.accessToken), not with the admin client. The database then applies the policies above, so even a bug in your route cannot leak another user's notes.
import { Router } from "express";
import { createUserClient } from "../config/supabase.js";
import { requireAuth } from "../middlewares/auth.js";
const router = Router();
router.use(requireAuth);
router.get("/", async (req, res) => {
const supabase = createUserClient(req.accessToken);
// No user_id filter needed: RLS only returns the caller's notes
const { data, error } = await supabase
.from("notes")
.select("*")
.order("created_at", { ascending: false });
if (error) return res.status(500).json({ message: error.message });
res.json(data);
});
router.post("/", async (req, res) => {
const supabase = createUserClient(req.accessToken);
const { data, error } = await supabase
.from("notes")
.insert({ title: req.body.title, body: req.body.body })
.select()
.single();
if (error) return res.status(400).json({ message: error.message });
res.status(201).json(data);
});
router.delete("/:id", async (req, res) => {
const supabase = createUserClient(req.accessToken);
const { data, error } = await supabase
.from("notes")
.delete()
.eq("id", req.params.id)
.select();
if (error) return res.status(500).json({ message: error.message });
// Someone else's note looks exactly like a missing one, so we do not reveal it exists
if (data.length === 0) return res.status(404).json({ message: "Note not found" });
res.json({ message: "Note deleted" });
});
export default router;
import express from "express";
import authRouter from "./routes/auth.js";
import noteRouter from "./routes/note.js";
const app = express();
app.use(express.json());
app.use("/auth", authRouter);
app.use("/notes", noteRouter);
app.listen(9999, () => console.log("Server running on port 9999"));
Which Client Should I Use?
| Task | Client |
|---|---|
| Sign up, log in, refresh a session | createAuthClient() |
| Read or write data that belongs to the logged-in user | createUserClient(token) |
| Verify a token in middleware | supabaseAdmin.auth.getUser() |
| Admin work: background jobs, reports, data across users | supabaseAdmin |
Testing
POST http://localhost:9999/auth/register
Content-Type: application/json
{ "email": "rizwan@example.com", "password": "a-strong-password" }
POST http://localhost:9999/auth/login
Content-Type: application/json
{ "email": "rizwan@example.com", "password": "a-strong-password" }
Copy access_token from the response and send it with your notes requests:
POST http://localhost:9999/notes
Authorization: Bearer <access_token>
Content-Type: application/json
{ "title": "First note", "body": "Hello Supabase" }
Register a second user, log in, and call GET /notes with their token. They see an empty list, even though the first user's note is in the same table.
Conclusion
In this doc, we used Supabase Auth for sign up and login, protected routes with a middleware that verifies the token, and used Row Level Security so the database itself keeps each user's data private. In the next doc, we will upload files to Supabase Storage.