The short version
Supabase stores auth in a separate auth schema — not your public schema. Every user has exactly one row in auth.users, one or more rows in auth.identities (one per login provider), and can hold multiple active auth.sessions, each backed by a refresh_tokens row. If you've seen tables you didn't create, these are the ones.
auth.users 1───* auth.identities
auth.users 1───* auth.sessions
auth.sessions 1───* auth.refresh_tokens
Why an auth schema at all
Supabase keeps auth separate from your app's tables. Your public schema is where your models live; auth is locked down and managed by GoTrue. You read from it, you don't write to it. That separation is why your migrations never touch these tables — and why introspecting a Supabase database shows a schema you didn't write.
The four tables that matter
auth.users — one row per user.
| Column | What it holds |
|---|---|
id | UUID PK; stable identifier |
email / phone | Contact + login identity |
encrypted_password | Bcrypt hash (email/password only) |
raw_app_meta_data | Provider claims: provider, providers, email |
raw_user_meta_data | Custom metadata you set |
is_sso_user | True when sign-in came via SSO |
confirmed_at | When primary identity was confirmed |
auth.identities — one row per login method. Email and GitHub = two rows. provider_id is the provider's identifier; identity_data is the raw claim payload. FK user_id → users.id.
auth.sessions — a browser/token session. One per device roughly. aal, user_agent, ip, not_after, factor_id (MFA).
auth.refresh_tokens — the long-lived token backing a session. Access tokens ~1h (JWT). Refresh tokens are long, with revoked flag and parent for token reuse detection.
How they relate (the joins you'll actually write)
select u.email, i.provider, s.id as session_id
from auth.users u
join auth.identities i on i.user_id = u.id
join auth.sessions s on s.user_id = u.id;
The 80% case: "who is signed in, through which provider, on which sessions." Every relationship is a plain foreign key — identities.user_id, sessions.user_id, refresh_tokens.session_id — exactly what a diagram turns into readable arrows. Paste a read-only connection string into dbdiagramr to trace them visually.
What's usually NOT your business
auth.instances, auth.audit_log_entries, and auth.schema_migrations are Supabase bookkeeping. audit_log_entries records admin actions; instances is multi-tenancy plumbing. If a diagram shows them, ignore them — your reads live in the other four.
Practical tips
- Never write to
authtables directly. Use the Supabase client / Admin API. Direct inserts create orphaned identities, unhashed passwords. - Foreign-key joins work across schemas.
auth.users.idis the same UUID you'd use inpublic— joinpublic.profiles.user_id → auth.users.id. - A user with no identity row is a sign of trouble. Every normal user has at least one. Orphaned identities without a users row = import bug.
- Sessions accumulate. Many sessions per user isn't a leak, it's devices and tabs.
See it live: the Supabase auth schema diagram was generated by introspecting a live database — users, identities, sessions, and refresh_tokens with foreign keys rendered as relationships.
FAQ
Where is the Supabase auth schema?
In the auth schema, separate from public — auth.users, auth.identities, auth.sessions, auth.refresh_tokens, plus internal tables.
What is auth.users used for?
Single source of truth for who can sign in. One row per user, with email/phone, password hash, metadata, and provider flags.
Why does one user have multiple identities?
Each login method is a separate auth.identities row. Email + GitHub + Google = three rows, all pointing at the same users.id.
What's the difference between a session and a refresh token?
Session is device/token context; refresh token is the long-lived credential that renews the short-lived JWT. One session maps to one refresh token chain.
Can I join auth tables to my public tables?
Yes — use auth.users.id as the join key with your public.* tables.