User Management for Self-Hosted Supabase: Admin API Guide

Learn to list, ban, soft-delete, and manage users in self-hosted Supabase with the GoTrue Admin API—service role security and honest gotchas included.

Cover Image for User Management for Self-Hosted Supabase: Admin API Guide

On Supabase Cloud, user management feels like a solved problem: the dashboard gives you a searchable user list, a ban button, and delete confirmation dialogs. On a self-hosted Supabase instance, Studio's user page is far more bare-bones—and every serious operation (banning an abusive account, soft-deleting for GDPR, bulk-inviting a customer's team) ends up going through the GoTrue Admin API instead.

That's not a bad thing. The Admin API is actually the more reliable path: it's scriptable, auditable, and identical across environments. But it's also powered by your service_role key, which means one careless line of code can expose full control of your auth system.

This guide covers the operations you'll actually need—listing, creating, banning, deleting, and generating auth links—plus the gotchas the docs gloss over, like why a banned user can keep using your API for another hour if you're not careful.

How the Admin API Works on Self-Hosted Instances

GoTrue (the Supabase Auth server) exposes admin endpoints under /auth/v1/admin/*, routed through your API gateway (Kong or Envoy) on port 8000. Authentication is a JWT signed with your instance's secret and carrying the service_role claim—the same SERVICE_ROLE_KEY from your .env file.

Two ways to call it. Raw HTTP:

curl -s "https://api.yourdomain.com/auth/v1/admin/users" \
  -H "apikey: $SERVICE_ROLE_KEY" \
  -H "Authorization: Bearer $SERVICE_ROLE_KEY"

Or the supabase-js admin client, which is what you want in application code:

import { createClient } from '@supabase/supabase-js';

// Server-side only. Never ship this key to a browser or mobile app.
const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SERVICE_ROLE_KEY!,
  { auth: { autoRefreshToken: false, persistSession: false } },
);

Everything under supabaseAdmin.auth.admin.* hits those GoTrue admin endpoints. The autoRefreshToken: false and persistSession: false options matter: the admin client should be a stateless tool, not something holding a session.

One self-hosting-specific note for 2026: Supabase changed the default API_EXTERNAL_URL handling so that auth URLs include the /auth/v1 path prefix. If your admin API calls suddenly 404 after an upgrade, check that first—we covered the details in our June 2026 breaking changes prep guide.

Listing and Inspecting Users

The foundation of everything else. The list endpoint is paginated, and you will need pagination once you pass 50 users:

const { data, error } = await supabaseAdmin.auth.admin.listUsers({
  page: 1,
  perPage: 100,
});

for (const user of data.users) {
  console.log(user.id, user.email, user.banned_until, user.last_sign_in_at);
}

There's no server-side search or filter on this endpoint—a long-standing community complaint. For anything beyond "iterate all users," query the auth.users table directly with SQL. Reading from auth schema tables is safe and often the better tool:

-- Users who haven't confirmed their email after 7 days
select id, email, created_at
from auth.users
where email_confirmed_at is null
  and created_at < now() - interval '7 days';

The rule of thumb: read auth.users with SQL freely, but write through the Admin API. GoTrue maintains invariants (identities, hashed fields, timestamps) that hand-written UPDATE statements can silently break.

Creating and Inviting Users

Two distinct flows that people regularly confuse:

createUser makes an account immediately, optionally pre-confirmed, and sends no email:

const { data, error } = await supabaseAdmin.auth.admin.createUser({
  email: '[email protected]',
  password: generateSecurePassword(),
  email_confirm: true, // skip the confirmation email entirely
  user_metadata: { plan: 'pro', seat_type: 'admin' },
});

inviteUserByEmail creates the account and sends an invite email through your SMTP configuration. If invites silently never arrive, the problem is almost always mail config, not the API—self-hosted instances don't ship with a working mailer, as anyone who's followed our team onboarding guide knows.

Use createUser for migrations and programmatic provisioning; use inviteUserByEmail when a human should set their own password.

Banning Users (and the Gotcha Nobody Mentions)

There's no dedicated "ban" endpoint. Banning is a property set through updateUserById:

// Ban for 30 days
await supabaseAdmin.auth.admin.updateUserById(userId, {
  ban_duration: '720h',
});

// Lift the ban
await supabaseAdmin.auth.admin.updateUserById(userId, {
  ban_duration: 'none',
});

The duration uses Go's format—ns, us, ms, s, m, h are valid units, so "30 days" is 720h and "forever" is something like 876000h (100 years). GoTrue writes the result to the banned_until column on auth.users.

Here's the gotcha: a ban only blocks new token issuance. A banned user can't sign in and can't refresh their session—but any access token they already hold keeps working until it expires. If your JWT_EXPIRY is set to 3600 seconds, a banned user retains up to an hour of authenticated API access after you hit the button.

If you need bans to take effect immediately, sign the user out everywhere at the same time:

await supabaseAdmin.auth.admin.updateUserById(userId, { ban_duration: '720h' });
await supabaseAdmin.auth.admin.signOut(userId, 'global'); // revoke refresh tokens

Even then, the existing access token survives until expiry—the only real fix is a short JWT expiry or checking banned_until in your RLS policies for truly sensitive tables. This is one of several reasons to keep token lifetimes tight on production instances.

Deleting Users: Hard vs. Soft

deleteUser takes a second argument that most people never notice:

// Hard delete: row removed from auth.users
await supabaseAdmin.auth.admin.deleteUser(userId);

// Soft delete: row kept, credentials scrubbed, deleted_at set
await supabaseAdmin.auth.admin.deleteUser(userId, true);

Hard delete removes the row entirely. If your application tables reference auth.users(id) with foreign keys—which is the standard Supabase pattern—the delete will either cascade (if you declared on delete cascade) or fail with a constraint violation (if you didn't). Both outcomes surprise people. Check your FK definitions before you need to delete someone.

Soft delete keeps the id in place and stamps deleted_at, so your foreign keys stay intact and historical records (orders, posts, audit rows) keep pointing at a real row. The user can never sign in again and the ID can't be reclaimed. For GDPR erasure requests, soft delete plus explicit scrubbing of your own tables' personal data is usually the cleaner workflow—your relational integrity survives while the personal data goes away.

Whichever you choose, take a backup first. Deletes through the Admin API are immediate and irreversible, and "I bulk-deleted the wrong filter of users" is a support ticket we'd rather you never write. Automated pre-operation backups are exactly the kind of thing scheduled backups exist for.

generateLink is the underrated endpoint of the bunch. It produces the same tokenized URLs GoTrue would normally email—signup confirmations, magic links, password recovery, email-change confirmations—but returns them to you instead of sending anything:

const { data } = await supabaseAdmin.auth.admin.generateLink({
  type: 'recovery',
  email: '[email protected]',
});
// data.properties.action_link → deliver however you like

This is how you route auth emails through your own transactional provider (Resend, Postmark, SES) with your own templates, instead of GoTrue's built-in SMTP sending. It's also invaluable for support tooling—"send me a fresh recovery link for this user" becomes a one-liner in your internal admin panel.

Treat generated links like passwords: they grant authentication as that user. Log that you generated one; never log the link itself.

Locking Down the Service Role Key

Every operation above rides on SERVICE_ROLE_KEY, which bypasses Row Level Security everywhere, not just in auth. Baseline rules:

  • Server-side only. Never in browser bundles, mobile apps, or NEXT_PUBLIC_* variables. Grep your frontend build output if you're not sure.
  • Scope access at the network layer. Your admin endpoints sit behind the same gateway as everything else, so a leaked key is exploitable from anywhere that can reach port 8000. Restricting gateway exposure follows the same logic as securing the Studio dashboard.
  • Rotate after any suspected exposure. On self-hosted instances that means re-signing keys from your JWT secret and redeploying dependent services—tedious, which is why prevention beats reaction.
  • Wrap, don't spread. Put admin operations behind a small internal service or server actions with their own authorization checks, rather than sprinkling the admin client across your codebase.

Where Supascale Fits

Supascale doesn't reimplement user management—the Admin API belongs to your application, and a management layer pretending otherwise would just get in your way. What Supascale handles is the instance-level plumbing around it: OAuth provider configuration through a UI instead of hand-edited env vars, automated S3 backups so bulk user operations are recoverable, and custom domains with SSL so your /auth/v1 endpoints live on a URL you control. One $39.99 purchase, unlimited projects—see pricing for the details.

Wrapping Up

The GoTrue Admin API is the real user management interface for self-hosted Supabase: listUsers plus direct SQL for reads, createUser/inviteUserByEmail for provisioning, ban_duration with a global sign-out for bans, soft deletes for GDPR-friendly removal, and generateLink for owning your email delivery. The two things to internalize are that bans don't invalidate existing access tokens, and that everything depends on a service role key that must never leave your servers.

If you're running self-hosted Supabase in production, pair your admin tooling with automated backups and locked-down auth configuration—Supascale takes care of that layer so your team can focus on the application on top of it.

Further Reading