Supabase Auth with Next.js: author sign-in and permissions

Supabase Auth with Next.js for a one-author blog: why checking that someone is signed in isn't enough, and why the author role belongs in app_metadata.

How do you use Supabase Auth with Next.js so that only the author can write posts? You need database policies that let only the author role write, and Next.js code that checks that role before showing the admin pages. Do you think checking "signed in" is enough? Let's test it.

The blog is so safe that nobody can write a post

In the previous post, I turned on Row Level Security for the posts table of blog-mini, the small blog this series builds step by step. As a result, a stranger holding the publishable key can only read published posts. But no role can add or edit posts through the API, including me. To publish a new post, I have to go to the Supabase Dashboard.

What's missing is a way for the database to know a request comes from the author. Supabase Auth handles sign-in. When someone signs in successfully, they get a JWT, a signed string that carries information about them. Every request after that carries the JWT, and the database runs the SQL as the authenticated role instead of anon.

The hard part is the next question. What should a policy check in that JWT? I tried three options, and the first two are wrong in ways that are very easy to fall into.

How can you test three sets of policies on your machine?

I'm still using PGlite like last time, a real PostgreSQL running inside Node, so you don't need Docker or a Supabase project to follow along. This post's folder continues from the previous one. The three old migrations and seed.sql stay the same; you can copy them from the Row Level Security post. seed.sql adds four sample posts: two published, one draft and one scheduled.

Folder structure
05-author-sign-in/
├── package.json                  # only @electric-sql/pglite 0.3.14
├── supabase/
│   ├── seed.sql                  # unchanged
│   └── migrations/
│       ├── 20261110080000_create_posts_table.sql   # unchanged
│       ├── 20261110090000_add_post_tags.sql        # unchanged
│       ├── 20261111080000_enable_rls_on_posts.sql  # unchanged
│       └── 20261112080000_author_permissions.sql   # NEW
├── policy-tests/
│   ├── a-any-signed-in-user.sql                    # NEW, first wrong option
│   └── b-user-metadata.sql                         # NEW, second wrong option
└── scripts/
    ├── fake-supabase.mjs                           # rewritten
    └── try-roles.mjs                               # NEW

PGlite doesn't have what Supabase adds on top of PostgreSQL, so fake-supabase.mjs creates it. Last time it only created two roles. This time it also creates the auth.jwt() and auth.uid() functions, plus a runAs function that runs a SQL statement as if it came from a specific person.

scripts/fake-supabase.mjs
// A minimal Supabase-like database with PGlite (real Postgres running inside Node).
// Supabase has the anon and authenticated roles and the auth.jwt(), auth.uid() functions built in; here I create them.
import { readdirSync, readFileSync } from "node:fs";
import { PGlite } from "@electric-sql/pglite";

// On Supabase, every request carries a JWT. The API layer decodes it, puts its contents (the claims) in the
// session setting request.jwt.claims, and only then runs the SQL. auth.jwt() just reads that setting back.
const FAKE_AUTH = `
  create role anon nologin;
  create role authenticated nologin;
  create schema auth;
  grant usage on schema auth to anon, authenticated;
  create function auth.jwt() returns jsonb language sql stable as $$
    select coalesce(nullif(current_setting('request.jwt.claims', true), ''), '{}')::jsonb $$;
  create function auth.uid() returns uuid language sql stable as $$
    select nullif(auth.jwt() ->> 'sub', '')::uuid $$;
`;

// migrationCount: how many migration files to run (in name order), then run seed.sql.
// extraSql: one more SQL file run last, used to try a set of policies that isn't in a migration.
export async function createDb(migrationCount, extraSql) {
  const db = new PGlite();
  await db.exec(FAKE_AUTH);
  for (const file of readdirSync("supabase/migrations").sort().slice(0, migrationCount)) {
    await db.exec(readFileSync(`supabase/migrations/${file}`, "utf8"));
  }
  await db.exec(readFileSync("supabase/seed.sql", "utf8"));
  if (extraSql) await db.exec(readFileSync(extraSql, "utf8"));
  return db;
}

// Run one SQL statement as if it came from a specific person. claims = null is someone not signed in (anon);
// with claims, it's a signed-in person (authenticated) with exactly that JWT content.
// Returns a short string: the result, the number of rows affected, or Postgres's error.
export async function runAs(db, claims, sql) {
  await db.query("select set_config('request.jwt.claims', $1, false)", [claims ? JSON.stringify(claims) : ""]);
  await db.exec(`set role ${claims ? "authenticated" : "anon"}`);
  try {
    const result = await db.query(sql);
    if (result.rows.length > 0) return result.rows.map((r) => Object.values(r).join(" | ")).join("; ");
    return `${result.affectedRows ?? 0} row${result.affectedRows === 1 ? "" : "s"}`;
  } catch (error) {
    return `ERROR ${error.message}`;
  } finally {
    await db.exec("reset role");
  }
}

This fake skips one important thing the real Supabase does: checking the JWT's signature. Here I write the JWT content myself and the database simply believes it. That's enough to test policies, because policies only read the content, but don't take it to mean that in real life anyone can write their own JWT.

The test script has four people do the same three things under each set of policies. Each person counts the posts they can see, edits the title of the hello post, then adds a new post. The table has four posts, so someone who sees 2 sees only the published ones, and someone who sees 4 also sees the draft and the scheduled post.

scripts/try-roles.mjs
// Try three sets of policies with four people, each doing three things: count visible posts, edit a title, add a post.
import { createDb, runAs } from "./fake-supabase.mjs";

// The JWT contents (claims) of each person. Supabase creates these when a user signs in.
const PEOPLE = [
  ["a stranger, not signed in", null],
  ["a visitor who just signed up", { sub: "00000000-0000-0000-0000-000000000002", role: "authenticated", app_metadata: {}, user_metadata: {} }],
  ["a visitor who wrote role=author into user_metadata", { sub: "00000000-0000-0000-0000-000000000003", role: "authenticated", app_metadata: {}, user_metadata: { role: "author" } }],
  ["the author (app_metadata.role=author)", { sub: "00000000-0000-0000-0000-000000000001", role: "authenticated", app_metadata: { role: "author" }, user_metadata: {} }],
];

const POLICY_SETS = [
  ["A. Anyone signed in may write", 3, "policy-tests/a-any-signed-in-user.sql"],
  ["B. Check user_metadata", 3, "policy-tests/b-user-metadata.sql"],
  ["C. Check app_metadata (the fourth migration)", 4, undefined],
];

for (const [name, migrationCount, extraSql] of POLICY_SETS) {
  console.log(`=== ${name} ===`);
  for (const [person, claims] of PEOPLE) {
    const db = await createDb(migrationCount, extraSql); // a fresh database per person, so nobody sees anyone else's changes
    const sees = await runAs(db, claims, "select count(*)::int from public.posts");
    const edit = await runAs(db, claims, "update public.posts set title = 'Edited' where slug = 'hello'");
    const add = await runAs(db, claims, "insert into public.posts (slug, title) values ('new-post', 'New post')");
    console.log(`${person}\n    sees ${sees} posts | edit: ${edit} | add: ${add}`);
  }
  console.log();
}

Run npm install once, then node scripts/try-roles.mjs. I'll go through the results of each set of policies.

What if everyone who is signed in can write?

This is the option I see most often in quick tutorials. The reasoning sounds fine: the blog has only one author, so whoever can sign in must be the author.

policy-tests/a-any-signed-in-user.sql
-- Option A (WRONG): anyone signed in may write.
grant insert, update, delete on public.posts to authenticated;

create policy "Signed-in users can read every post"
on public.posts for select
to authenticated
using (true);

create policy "Signed-in users can add posts"
on public.posts for insert
to authenticated
with check (true);

create policy "Signed-in users can edit posts"
on public.posts for update
to authenticated
using (true)
with check (true);
node scripts/try-roles.mjs (part A)
=== A. Anyone signed in may write ===
a stranger, not signed in
    sees 2 posts | edit: ERROR permission denied for table posts | add: ERROR permission denied for table posts
a visitor who just signed up
    sees 4 posts | edit: 1 row | add: 1 row
a visitor who wrote role=author into user_metadata
    sees 4 posts | edit: 1 row | add: 1 row
the author (app_metadata.role=author)
    sees 4 posts | edit: 1 row | add: 1 row

The line to look at is "a visitor who just signed up". This person sees all 4 posts, can edit the title and can add a post.

The reasoning was wrong because "can sign in" doesn't mean "is the author". Supabase Auth has a sign-up API, and it only needs the publishable key, which is public in your page's JavaScript. If your project still has "Allow new users to sign up" turned on in the Auth settings, anyone can create an account and sign in. Not having a sign-up page on your blog doesn't change that, because people call the API directly.

Turning off sign-ups is a good idea for a one-author blog. But I don't want the safety of my posts to depend on a Dashboard switch that someone might turn back on. The policy should be correct on its own.

Is it safe to put the role in user_metadata?

The natural next step is to give the account a role and have the policy check it. Every Supabase account has two places for extra data, user_metadata and app_metadata, and both show up in the JWT. The name user_metadata sounds like exactly the place for information about the user, so it's an easy choice.

policy-tests/b-user-metadata.sql
-- Option B (WRONG): check the role in user_metadata, which users can edit themselves.
grant insert, update, delete on public.posts to authenticated;

create policy "Authors can read every post"
on public.posts for select
to authenticated
using ((select auth.jwt() -> 'user_metadata' ->> 'role') = 'author');

create policy "Authors can add posts"
on public.posts for insert
to authenticated
with check ((select auth.jwt() -> 'user_metadata' ->> 'role') = 'author');

create policy "Authors can edit posts"
on public.posts for update
to authenticated
using ((select auth.jwt() -> 'user_metadata' ->> 'role') = 'author')
with check ((select auth.jwt() -> 'user_metadata' ->> 'role') = 'author');
node scripts/try-roles.mjs (part B)
=== B. Check user_metadata ===
a stranger, not signed in
    sees 2 posts | edit: ERROR permission denied for table posts | add: ERROR permission denied for table posts
a visitor who just signed up
    sees 2 posts | edit: 0 rows | add: ERROR new row violates row-level security policy for table "posts"
a visitor who wrote role=author into user_metadata
    sees 4 posts | edit: 1 row | add: 1 row
the author (app_metadata.role=author)
    sees 2 posts | edit: 0 rows | add: ERROR new row violates row-level security policy for table "posts"

The visitor who just signed up is blocked now. But the third person isn't, because they wrote role: "author" into their own user_metadata. They can do that because that's what this field is for. Supabase's Row Level Security docs say user_metadata can be updated by the signed-in user, and that a policy relying on it can create security issues. The same docs say app_metadata can't be updated by the user, which makes it a good place for authorization data.

The last line is worth a look too. The real author, whose role is in app_metadata, gets refused. This set of policies lets the wrong person in and keeps the right person out.

What does a correct author policy look like?

blog-mini's fourth migration is the same as option B, with user_metadata changed to app_metadata and a policy added for deleting. I created it with npx supabase migration new author_permissions.

supabase/migrations/20261112080000_author_permissions.sql
-- The author (a signed-in user with app_metadata.role = 'author') may read every post, and add, edit and delete posts.
-- app_metadata can only be changed from the admin side (SQL, Dashboard); users can't change it themselves.

-- Layer 1: the authenticated role may write to the table.
grant insert, update, delete on public.posts to authenticated;

-- Layer 2: but only the rows a policy allows, and the policies only accept the author.
create policy "Authors can read every post"
on public.posts for select
to authenticated
using ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'author');

create policy "Authors can add posts"
on public.posts for insert
to authenticated
with check ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'author');

create policy "Authors can edit posts"
on public.posts for update
to authenticated
using ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'author')
with check ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'author');

create policy "Authors can delete posts"
on public.posts for delete
to authenticated
using ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'author');

The update policy has both using and with check, and they do different jobs. using decides which rows may be picked for editing. with check checks the row after the edit. With a condition that only looks at the JWT, like here, the two are the same. When the condition looks at the row's data, for example "only edit your own posts", leaving out with check lets someone edit their post into somebody else's post.

The author's read policy doesn't replace the read policy from last time. Two policies for the same operation are combined with "or", so a signed-in person sees a row when it's published, or when they are the author.

node scripts/try-roles.mjs (part C)
=== C. Check app_metadata (the fourth migration) ===
a stranger, not signed in
    sees 2 posts | edit: ERROR permission denied for table posts | add: ERROR permission denied for table posts
a visitor who just signed up
    sees 2 posts | edit: 0 rows | add: ERROR new row violates row-level security policy for table "posts"
a visitor who wrote role=author into user_metadata
    sees 2 posts | edit: 0 rows | add: ERROR new row violates row-level security policy for table "posts"
the author (app_metadata.role=author)
    sees 4 posts | edit: 1 row | add: 1 row

Now only the last person can write. Notice the two different kinds of refusal in the output, too.

The stranger gets "permission denied" because the anon role has no grant to write. The two visitors do have the grant, so their update runs without an error; it just changes 0 rows because the policy doesn't let them pick any row. The insert does raise a clear error. If your code only checks "was there an error" after an update, it will report success when nothing was saved.

So who can write to app_metadata? Only the admin side. For Tech Notes, I created the account in the Dashboard and then ran exactly one statement in the SQL Editor. It's in the repo's internal docs:

Granting the author role (run in the Supabase SQL Editor)
update auth.users
set raw_app_meta_data = raw_app_meta_data || '{"role":"author"}'
where email = 'author-email@example.com';

There's one thing to know after running it. The JWT is issued at sign-in and carries the app_metadata of that moment. Supabase's docs remind you that a JWT is not always up to date. So after granting or removing a role, the user has to sign in again, or wait for the token to refresh, before the change takes effect.

The real Tech Notes policies live in 20261103141232_create_posts.sql and match the ones above, plus one extra condition in the insert policy, author_id = (select auth.uid()), so an author can't create a post under someone else's name.

What does the Next.js side need to do?

The database can now protect itself. The Next.js side handles three things: keeping the session in a cookie, keeping non-authors out of the admin pages, and checking again on every write. This part needs a real Supabase project, so I can't run it with PGlite. The snippets below are Tech Notes code running in production, copied as is.

The first piece is a function that answers "is this JWT content the author's?". It reads only app_metadata, just like the policies:

src/lib/author.ts (Tech Notes)
type Claims = { sub?: string; email?: string; app_metadata?: { [key: string]: unknown } | null } | null | undefined;

/**
 * Authorship comes only from app_metadata, which users cannot edit. user_metadata is ignored on
 * purpose: it is writable by the user and would let anyone promote themselves.
 */
export function isAuthorClaims(claims: Claims): claims is { sub: string; email?: string; app_metadata: { role: "author" } } {
  return typeof claims?.sub === "string" && claims.app_metadata?.role === "author";
}

The second piece is getting the JWT content in a trustworthy way. The @supabase/ssr package creates a client that reads the session from the request's cookies. That client has three functions that sound alike, and picking the wrong one leaves a hole.

Supabase's Next.js Auth guide says to always use supabase.auth.getClaims() to protect pages and user data, and never to trust getSession() in server code. The reason is that getSession() just reads the cookie without checking it again, and anyone can set a cookie in their own browser. getClaims() verifies the token's signature on every call.

src/lib/auth.ts (Tech Notes)
/** Verifies the session JWT (getClaims) and returns the author, or null for anyone else. */
export async function getAuthor() {
  if (!getSupabaseEnv()) return null;
  const supabase = await createServerSupabase();
  const { data, error } = await supabase.auth.getClaims();
  const claims = error ? null : data?.claims;
  if (!isAuthorClaims(claims)) return null;
  return { supabase, userId: claims.sub, email: claims.email ?? "" };
}

/** For admin pages and Server Actions: every entry point checks again, the proxy is not enough. */
export async function requireAuthor() {
  const author = await getAuthor();
  if (!author) redirect(LOGIN_PATH);
  return author;
}

createServerSupabase is the function that creates the client from cookies, written following the guide above, so I won't copy it here.

The third piece is blocking at the door. Next.js 16 has a proxy.ts file that runs before every request; this file used to be called middleware.ts. In Tech Notes, every path that starts with /admin goes through a function that does two things. It calls getClaims() to refresh a token that's about to expire, and it sends anyone who isn't the author to the sign-in page:

src/proxy.ts (Tech Notes, the check)
const { data } = await supabase.auth.getClaims();
const isLoginPage = request.nextUrl.pathname === LOGIN_PATH;

if (!isLoginPage && !isAuthorClaims(data?.claims)) {
  const redirect = NextResponse.redirect(new URL(LOGIN_PATH, request.url));
  // Keep any refreshed/cleared auth cookies on the redirect response.
  for (const cookie of response.cookies.getAll()) redirect.cookies.set(cookie);
  redirect.headers.set("cache-control", "private, no-store");
  return redirect;
}

You might ask why requireAuthor() is still needed when the proxy already blocks. I keep both because the proxy only covers pages. A Server Action is an endpoint that can be called directly with a POST request, and it shouldn't depend on which pages the caller went through. That's why the first line of every action that writes data in Tech Notes is const { supabase } = await requireAuthor();.

Finally, the sign-in action. Two small details in it are worth copying. The real messages are in Vietnamese; I've translated them here:

src/app/admin/actions.ts (Tech Notes, sign-in action, messages translated)
const supabase = await createServerSupabase();
const { error } = await supabase.auth.signInWithPassword({ email, password });
// One generic message for every failure so the form does not reveal which accounts exist.
if (error) return { error: "Wrong email or password." };

const { data } = await supabase.auth.getClaims();
if (!isAuthorClaims(data?.claims)) {
  await supabase.auth.signOut();
  return { error: "This account doesn't have author access yet." };
}

redirect("/admin");

The first detail is that every sign-in failure uses the same message. If the form answered "email doesn't exist" in one case and "wrong password" in another, a stranger could find out which emails have accounts. The second is that an account with the right password but no author role is signed out right away, instead of keeping a useless session.

Every page view asks the database again

The author can now sign in and write posts through the API, while strangers can't, even if they sign up for an account or give themselves a role.

But there's still one thing in blog-mini I'm not happy with, and it's on the readers' side. The home page and post pages call Supabase on every view. A published post almost never changes, yet a thousand readers means a thousand identical queries, readers have to wait for that query, and the free tier has limits. I want pages built ahead of time like static pages, but still updated when the author edits a post. That's the job of the next posts.

You can follow the whole series at Build a blog with Next.js + Supabase. If you're preparing for interviews, the authentication and database questions are in the interview question bank.

Get the next post by email.

One email a week, only when there is something worth reading. No spam.