Part 4 — Running It in Production

P Authentication, Accounts and Access

This chapter takes a person standing in front of a login box and follows them all the way through to a database row they are allowed to read. It covers how logins actually work, which login method to pick for a wholesale apparel ERP and why, how to run multi-factor authentication and sessions for real employees on real schedules, how to invite and remove staff, and how a role matrix layers on top of the row-level security from chapter 5. It ends with a complete, runnable Supabase Auth implementation. Every price, limit and product behavior below was checked against the vendor's own documentation in July 2026, and the figures that will date carry that check date next to them.

In this chapter14 sections · about 93 min
  1. What you need to know first
  2. Authentication versus authorization, concretely
  3. Sessions, cookies and tokens from zero
  4. Login methods compared honestly
  5. Why the rep at a trade show needs a different login than the controller at a desk
  6. Multi-factor authentication
  7. Session management for business users
  8. Onboarding a customer
  9. Roles and permissions in practice
  10. Customer-facing access: the B2B portal
  11. Service accounts and API keys
  12. Account security operations
  13. Auditing authentication events
  14. A complete worked implementation with Supabase Auth

What you need to know first

Before any of the rest makes sense, a handful of ideas need to be solid. None of them are hard. They are just usually explained badly.

Statelessness, cookies and tokens

A request is stateless. When your browser asks your server for a page, that request arrives with no memory of anything that happened before. The server has no idea who you are. Every single request has to carry proof of identity with it, or the server treats you as a stranger. Everything in this chapter exists to solve that one problem: how does request number 4,000 prove it comes from the same person who typed a password an hour ago.

A cookie is a small piece of text the server asks the browser to store and send back. The server responds with a header that says Set-Cookie: session=abc123. The browser writes that down and attaches Cookie: session=abc123 to every future request to that site, automatically, until the cookie expires or is deleted. You do not write code to send it. The browser does it.

That automatic-attachment behavior is why cookies are convenient, and it is also why cross-site request forgery exists. Cross-site request forgery (CSRF) is an attack where a page on some other website makes your browser fire a request at your ERP, and your browser helpfully attaches your cookie to it. We deal with that below.

A token is a string that proves something. That is the entire definition. A token can be a random 32-byte value that means nothing on its own and only has meaning because your database has a row matching it. Or a token can be self-describing: it carries the facts inside itself and a cryptographic signature proving those facts were not edited. The first kind is called an opaque token or a reference token. The second kind is what a JWT is. Both are "tokens". They behave completely differently, and confusing them causes most auth bugs.

Hashing, DNS records and TLS

Hashing is one-way. Encryption is two-way. If you encrypt something you can decrypt it later with the key. If you hash something you can never get it back; you can only hash a new input and see whether the two hashes match.

Passwords must be hashed, never encrypted, using a slow algorithm designed for the job — bcrypt, scrypt or Argon2, so an attacker who steals your database cannot test billions of guesses per second. Supabase stores password hashes with bcrypt in the auth.users table and you never see them. Invitation tokens and API keys should also be stored hashed, for the same reason, which we build below.

DNS is a lookup table for names, and you will be editing it. Your domain registrar or DNS host (Cloudflare, Route 53, Namecheap) holds a set of records:

  • An A record maps a name to an IPv4 address.
  • A CNAME (canonical name) record maps a name to another name, so lookups follow a pointer.
  • A TXT record holds arbitrary text, and email authentication uses TXT records to publish policy.
  • An MX (mail exchanger) record says where mail for the domain goes.

You will add TXT records for SPF and DMARC, plus TXT or CNAME records for DKIM, so that your password-reset emails actually arrive. DNS changes are not instant: each record has a TTL (time to live, in seconds) and resolvers cache the old answer until it expires. Set TTL to 300 while you are experimenting.

TLS is the padlock. Turn it on everywhere. TLS (Transport Layer Security, the successor to SSL) encrypts the connection between browser and server so nobody on the coffee-shop wifi can read the cookie going past. Any cookie that grants access must be marked Secure, which tells the browser to refuse to send it over plain HTTP.

Certificates are issued automatically these days through ACME (Automatic Certificate Management Environment) — a protocol where your host proves to a certificate authority such as Let's Encrypt that it controls the domain, gets a certificate back, and renews it on a timer without anyone watching. Vercel, Supabase and Cloudflare all run ACME for domains you attach, so you will rarely touch a certificate by hand. Never disable TLS "just for testing" on a domain that also serves real sessions.

JWKS, PKCE and who vouches for whom

Two more acronyms you will meet in this chapter. JWKS (JSON Web Key Set) is a small public file your auth server publishes at a fixed URL, listing the public keys anyone can use to check a JWT's signature. PKCE (Proof Key for Code Exchange, pronounced "pixy") is a handshake that stops someone who intercepts a login link or redirect code from redeeming it, because only the browser that started the flow holds the matching secret. Supabase uses PKCE for email links and OAuth by default; you do not implement it, you just make sure the callback route exists.

Two pieces of identity vocabulary. An identity provider (IdP) is the system that holds the truth about who someone is and vouches for them — Google Workspace, Microsoft Entra ID (formerly Azure AD), Okta, or Supabase Auth itself. A relying party is your app, which trusts that vouching. Enterprise single sign-on (SSO) is the arrangement where the customer's IdP does the authenticating and your app accepts the answer.

A word about scale. A wholesale apparel brand in the $10M–$80M revenue range usually has somewhere between a dozen and a couple of hundred internal users. The count varies widely with how much of the warehouse and sales force is in-house rather than outsourced. Add a few hundred more if you open a B2B portal for the retailers who buy from you. Every auth vendor's free or entry tier covers that comfortably, so choose on operational fit and support burden, not price per user.

Core principle

Authentication answers "who is this?" once, at the door. Authorization answers "may they do this?" on every single request afterwards. Getting the door right is a weekend of work. Getting the second question right, consistently, in every query, is the actual job, and chapter 5's row-level security is how you make the database enforce it instead of trusting your application code to remember.

Authentication versus authorization, concretely

Authentication is proving identity. Authorization is granting permission. People conflate them because the login screen is where both usually get set up at the same moment. Separating them in your head has a practical payoff: it tells you where each bug lives.

In your ERP the split runs like this. Maria types her email and password. Supabase Auth checks the bcrypt hash, confirms it matches, and issues her a session. That is authentication, and it happened exactly once. Maria now clicks into an order for account "Nordstrom", changes the ship date, and saves. Before that write lands, something must decide whether Maria — a sales rep, not a sales manager — may change ship dates on an account that belongs to a different rep. That is authorization, and it happens on that request and every other one.

The failure modes differ too. An authentication failure is loud: the user cannot get in, they call you, you fix it. An authorization failure is silent, and nobody notices for six months until a rep screenshots another rep's margin sheet into a group chat.

So authorization must be enforced where it cannot be bypassed. Check permissions only in your React components and anyone who opens the network tab and replays a request walks straight past you. Check only in your API route handlers and you are one forgotten if away from a leak. Chapter 5 puts the check in Postgres row-level security, where every query — from your app, from a script, from a mistake — goes through the same gate. This chapter's job is to hand that gate an accurate, unforgeable statement of who the requester is and what role they hold.

Sessions, cookies and tokens from zero

The oldest and still one of the safest patterns works like this. You log in. The server generates a long random string — OWASP's session management guidance asks for at least 64 bits of entropy, produced by a cryptographically secure random generator of at least 128 bits, and stores a row keyed by that string containing your user id, creation time, and last-seen time. The server sends the random string back as a cookie. On each request the server looks the string up, finds the row, and knows who you are.

The random string means nothing on its own. It is a coat check ticket, and its power comes entirely from the row in your database, which means you control it completely. Delete the row and the session is dead on the very next request. Change someone's role and the next lookup sees the new role. Revocation is instant and free. The cost is a database read per request, which is irrelevant at 40 concurrent users and is why consumer-scale sites moved to JWTs.

What a JWT actually is

A JSON Web Token is three chunks of Base64URL text joined by dots: header.payload.signature. The header says which algorithm signed it. The payload is JSON containing claims — facts about the subject. The signature is a cryptographic proof that whoever holds the signing key produced exactly this header and payload.

eyJhbGciOiJFUzI1NiIsImtpZCI6IjJhNDMi...   <- header
.eyJzdWIiOiIzZmE4NS0uLi4iLCJyb2xlIjoi...  <- payload
.MEUCIQDx8kR2vT1p9nQ0hZfL4mYc2wJ...       <- signature

decoded payload:
{
  "iss": "https://abcdefgh.supabase.co/auth/v1",
  "sub": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "aud": "authenticated",
  "role": "authenticated",
  "email": "maria@brandco.com",
  "aal": "aal2",
  "amr": [{"method": "password", "timestamp": 1753372800},
          {"method": "totp",     "timestamp": 1753372812}],
  "session_id": "9c1b0e7a-4d2f-4a11-b8c5-7e1f0d3a2b44",
  "app_metadata": {"tenant_id": "b21c...", "role": "sales_rep"},
  "iat": 1753372812,
  "exp": 1753376412
}

Read that payload left to right:

  • iss is who issued it.
  • sub is the subject — the user's UUID, the thing you join on. A UUID is a long random identifier, unique enough that two systems will never generate the same one.
  • aud is the intended audience; Supabase sets it to authenticated, and PowerSync has to be configured to accept that exact value.

PowerSync's Supabase setup guide warns that if you skip adding it, you get PSYNC_S2105 errors ("JWT payload is missing a required claim 'aud'"), though a standard Supabase project is auto-detected and configured for you.

The rest of the payload:

  • role is the Postgres role the query will run as.
  • aal is the Authenticator Assurance Level — aal1 means one factor was used, aal2 means a second factor was verified too.
  • amr (authentication methods references) lists the actual methods and when each happened.
  • session_id ties the token back to a server-side session row.
  • app_metadata is where you put claims the user must not be able to edit.
  • iat and exp are issued-at and expiry, both Unix seconds; here they are one hour apart, which is Supabase's default access-token lifetime (checked July 2026).

Three things people get wrong about JWTs:

  1. Encoding is not encryption. Anyone can paste the payload into a Base64 decoder and read every claim, so never put anything secret in it.
  2. The signature only proves the token was not tampered with. It says nothing about whether the token is still appropriate for the person holding it.
  3. Most important of all: a JWT cannot be revoked. Once issued, any server holding the public key will accept it until exp passes. If you fire someone at 10:02 and their token expires at 10:59, they have 57 minutes of valid access unless you build something extra.

For a business application the revocation problem dominates. Offboarding must be immediate. Role changes must take effect now, not "within the hour". A controller who discovers a rep is quoting unauthorized discounts wants that rep locked out while they are still on the phone.

Storage is the other reason. A JWT held in browser localStorage is readable by any JavaScript on the page, so one compromised npm dependency or one cross-site scripting hole hands over a working credential. Cross-site scripting (XSS) is a bug where attacker-supplied text gets executed as code inside your page. A cookie marked HttpOnly is invisible to JavaScript: the browser still sends it, but no script can read it, so an XSS bug becomes "attacker can act while the page is open" rather than "attacker exfiltrates a token and uses it from elsewhere for the next hour".

Set-Cookie: __Host-erp-session=eyJhbGciOi...;
            Path=/;
            Secure;
            HttpOnly;
            SameSite=Lax;
            Max-Age=3600

Every attribute here earns its place:

  • __Host- is a browser-enforced prefix: the browser rejects the cookie unless it also has Secure, has Path=/, and has no Domain attribute, which stops a compromised subdomain from planting a cookie on your main site.
  • Secure means HTTPS only.
  • HttpOnly means JavaScript cannot read it.
  • SameSite=Lax means the browser sends it on normal top-level navigations to your site but not on cross-site form posts or background fetches, which blocks most CSRF.
  • Max-Age=3600 is one hour in seconds.

Use SameSite=Strict if you have no inbound links from email that need to land already-logged-in; use Lax if you do, which for an ERP with emailed order links you probably do.

One practical note: Supabase does not name its cookies with the __Host- prefix — it writes sb-<project-ref>-auth-token, split across numbered chunks when the token is long. You control the rest of the attributes through the cookieOptions you pass when you create the server client.

The hybrid that Supabase actually uses, and why it is fine

Supabase Auth issues two things: a short-lived JWT access token (one hour by default) and a long-lived refresh token. The refresh token is opaque, single-use, and backed by a server-side session row. When the access token expires, the client trades the refresh token for a fresh pair.

That gives you a bounded version of the revocation problem. Kill the session server-side and the refresh fails, so the blast radius is at most one access-token lifetime. Supabase's refresh tokens have a reuse interval — 10 seconds by default, and the docs advise against changing it, so a legitimate double-refresh from two server-rendered requests does not blow up the session. A genuinely stolen refresh token used outside that window trips reuse detection, and Supabase terminates the session and revokes its refresh tokens.

The important operational decision is where the tokens live. With the @supabase/ssr package in Next.js, they live in cookies, set from the server, and a small function that Next.js runs on every request before your page code refreshes them. Next.js used to call that file middleware.ts and now calls it proxy.ts; step 6 shows both names. That gets you cookie security with JWT performance. Do not use the browser-only client with localStorage for an ERP.

Never trust getSession() on the server

Supabase's own Next.js server-side guide is blunt: the session "is loaded directly from local storage and isn't re-validated against the Auth server", so it must not be trusted in server code. Use supabase.auth.getClaims() to protect pages and data, it verifies the JWT signature against your project's published public keys on every call, or getUser() when you need a fresh round-trip to the auth server. Reserve getSession() for the case where you need the raw token string to forward to another service such as PowerSync.

Login methods compared honestly

Five realistic options, compared in the table below and then taken one at a time, with a recommendation at the end.

MethodUser experienceMain failure modeSupport burdenUse in a wholesale ERP
Email + passwordFamiliar; fast on repeat; needs no second channelForgotten passwords; reuse of a breached passwordMedium — resets are a steady source of tickets, but self-serveDefault for all internal staff
Magic linkNo password to remember; one clickEmail delay or spam folder blocks login entirely; corporate scanners pre-click and burn the link; link opens in the wrong browserHigh during outages, near zero otherwiseGood for the retailer B2B portal; poor at a trade show
One-time code (OTP)Type 6 digits; survives cross-browser and cross-deviceSame email dependency; code entry on a phone keyboardMediumBetter than magic link on iPads and shared devices
Social login (Google/Microsoft)One tap if already signed in to work accountPersonal vs work account confusion; account lost when employee's Google account is deletedLow — provider handles resetsStrong if the customer is a Google Workspace or Microsoft 365 shop, which many apparel brands are
Enterprise SSO (SAML / OIDC)Invisible: the IdP already knows themMetadata expiry, clock skew, certificate rollover; misconfigured attribute mappingLow daily, spiky at setup and cert renewalOnly when the customer's IT department demands it, which is usually your larger accounts

Email and password

Everyone understands it. It needs one round trip and no second channel, so it works on bad wifi, on a borrowed laptop, and on the day your transactional email provider has an incident.

The modern rules for passwords are shorter than you expect. NIST SP 800-63B revision 4 (published 26 August 2025) tells verifiers they "SHALL NOT impose other composition rules (e.g., requiring mixtures of different character types) for passwords" and "SHALL NOT require subscribers to change passwords periodically". On length it says verifiers "SHALL require passwords that are used as a single-factor authentication mechanism to be a minimum of 15 characters in length", and MAY allow a shorter password when it is only part of a multi-factor flow, but never below eight characters.

Forced changes happen only on evidence of compromise. So: a 12-character minimum with mandatory second factor for anyone who matters, no character-class rules, and leaked-password checking against the Have I Been Pwned Pwned Passwords corpus, which Supabase offers on Pro and above (checked July 2026).

A magic link mails a URL that logs you in when clicked. Supabase's magic links "expire after 1 hour", are one-time use, and a given user may only request one every 60 seconds (checked July 2026).

The failure modes are not obvious, so learn them before you commit to this. Corporate email security appliances follow every link in every message to check for malware, and because the link is single use, the scanner consumes it, so the user clicks and gets "link expired". Supabase's email-template documentation describes this exact problem: "certain email providers may have spam detection or other security features that prefetch URL links from incoming emails". Its suggested fixes are to send a six-digit code instead of a link, or to build your own link that lands on a page with a button the user must press.

Some mail clients open links in an in-app browser with no cookie jar shared with Safari, so the session lands somewhere the user cannot get back to. And if your email is delayed by four minutes, your login is delayed by four minutes, with the user staring at a blank screen.

One-time code

Same email round trip, but the user types six digits instead of clicking. This solves the scanner problem (scanners cannot type), solves the wrong-browser problem, and works when the user is reading email on their phone but logging in on an iPad. In Supabase this is the same signInWithOtp call; whether the recipient gets a link or a code depends on whether your email template uses {{ .ConfirmationURL }} or {{ .Token }}.

Social login

"Sign in with Google" or "Sign in with Microsoft" delegates authentication to a provider the customer already pays for. Password resets, MFA and offboarding all become the customer's IT problem, which is where they belong. The risk is account confusion: a rep signs up with their personal Gmail on day one, and eighteen months of order history is attached to an identity you cannot control. Restrict the allowed email domains at signup and refuse anything else.

Enterprise SSO via SAML or OIDC

SAML 2.0 (Security Assertion Markup Language) and OIDC (OpenID Connect) are protocols for "let the customer's identity provider vouch for this person". OIDC is the modern one. It is built on OAuth 2.0, the standard for granting one site limited access to an account held somewhere else, and it passes identity around as JSON and JWTs. SAML is the older XML-based one, and it is still what enterprise IT departments ask for by name.

Supabase supports SAML 2.0 on Pro and above. It is off by default and configured through the CLI rather than the dashboard. The docs state you need CLI version v1.46.4 or higher (checked July 2026).

# Register the customer's identity provider.
# Requires Supabase CLI v1.46.4 or newer.
supabase sso add --type saml \
  --project-ref abcdefghijklmnop \
  --metadata-url 'https://retailco.com/idp/saml/metadata' \
  --domains retailco.com

# Or from a metadata XML file they email you:
supabase sso add --type saml \
  --project-ref abcdefghijklmnop \
  --metadata-file ./retailco-idp-metadata.xml \
  --domains retailco.com

# Map their attribute names onto your claims later:
supabase sso update <provider-uuid> \
  --project-ref abcdefghijklmnop \
  --attribute-mapping-file ./retailco-mapping.json

The first command tells Supabase: "any user whose email ends in retailco.com should be bounced to this identity provider". The metadata URL is a document the customer's IdP publishes describing its signing certificate and endpoints; using the URL rather than a file means certificate rotations are picked up automatically, which removes a common cause of SSO outages.

On the client you then call signInWithSSO({ domain: 'retailco.com' }) and the browser redirects out and back. The attribute mapping file translates the IdP's field names (which will be something like http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress) into your own claim names. Note that a SAML user cannot register a passkey in Supabase, so if you turn SSO on for a domain, the IdP owns that account's second factor too.

Cost check, every figure read off the vendor's pricing page in July 2026 and all subject to change:

  • Supabase Pro is $25/month and includes 50 SSO monthly active users, then $0.015 per SSO MAU.
  • WorkOS charges $125 per SSO connection per month for the first 15 connections, dropping to $100 for connections 16–30 and lower again above that; its AuthKit user-management product is free up to 1 million monthly active users, then $2,500 per additional million.
  • Clerk's Pro plan is $25/month, includes one enterprise connection per app, and charges $75/month for each additional connection in the 2–15 range.
  • Auth0's Essentials tier starts at $150/month for 500 MAU and includes 3 enterprise connections, with extras at $100/month up to 30 total.

Per-connection pricing is the industry norm, so SSO cost scales with your number of customers, not your number of users.

Recommended default

Email and password, with mandatory TOTP multi-factor for any role that can move money or change prices, plus optional "Sign in with Microsoft / Google" for customers already on a managed work account. Add one-time email codes as the recovery path. Add SAML SSO only when a specific customer's IT department asks and will pay for it. This combination fails gracefully — if your email provider is down, staff can still log in, and it is the cheapest to support.

Why the rep at a trade show needs a different login than the controller at a desk

Consider two real days. In February your sales rep is at Coterie in New York, standing with an iPad in a booth alongside hundreds of other exhibitors on the same overloaded venue wifi. A buyer from a 12-door boutique chain is in front of her and has eleven minutes. If the app asks her to log in again, she loses the buyer.

Meanwhile your controller is at a desk with a wired connection, a password manager and a laptop that never leaves the building, releasing a payment run against factory invoices. If the app asks him to re-authenticate first, he shrugs and does it. Those two people need opposite defaults.

DimensionSales rep on iPad at a showController at a desk
NetworkCongested, intermittent, sometimes noneReliable wired or office wifi
Login friction toleranceNear zero during show hoursHigh; re-auth on sensitive actions is fine
Second factorDevice-bound (Face ID / device passcode); an SMS code is unusable with no signalTOTP app or passkey, no problem
Session lengthLong: must survive a 10-hour show day and ideally the whole showShort: 8-hour absolute, 30-minute idle
Device trustCompany-owned, MDM-enrolled, supervised iPadCompany laptop, disk encrypted
Blast radius if stolenOwn accounts only, no pricing edits, no bankingEverything financial, so lock it down hard

MDM, blast radius and two policies

MDM means mobile device management: software (Jamf, Kandji, Mosyle, Microsoft Intune) that the company installs on a device it owns, so it can push settings, force a passcode, lock the device into one app, and wipe it remotely when it goes missing. An MDM-enrolled iPad is a device you can still control after it walks out of the booth, which is why it earns a longer session than a random browser. One more phrase from that table: "blast radius" means how much damage one stolen credential can do before anyone notices.

The design that follows: log the rep in before the show, on the office wifi, on a company iPad enrolled in MDM, with a long session and a device-bound second factor. Give the rep an app that works fully offline (chapter 6), so a dropped connection never turns into a login prompt. Give the controller a shorter session, mandatory TOTP, and step-up re-authentication on payment release — step-up meaning you ask for the password or a fresh code again at the moment of the dangerous action, even though the user is already signed in. Same system, two policies, keyed off role and device.

Concretely, add a "trusted device" concept: a company iPad that has completed enrollment gets a device record, and sessions on that device get the long timebox. A browser on an unknown machine gets the short one. We store that as a device row and a claim, shown later.

Multi-factor authentication

A factor is a category of evidence: something you know (password), something you have (phone, security key), something you are (fingerprint, face). Multi-factor means evidence from two different categories. Two passwords still count as one factor, because both are things you know. A password plus a code from an app on your phone counts as two.

FactorHow it worksPhishing-resistant?Works offline?Verdict for an ERP
TOTP authenticator appShared secret plus current time generates a 6-digit code every 30sNo — a fake login page can relay the code in real timeYes, fullyBaseline. Require for privileged roles.
Passkey / WebAuthnPublic-key pair bound to your domain; unlocked by biometric or device PINYes — the browser refuses to sign for the wrong originYes (local unlock)The right direction; Supabase support is experimental as of July 2026
Push approvalProvider app shows "Approve?"; user tapsNo, and open to approval fatigue — the attacker fires prompts all night until the user taps to make them stopNo — needs dataOnly with number matching: the login page shows a number the user must find on the prompt
SMS codeCode texted to a phone numberNoNo — needs cell signalWeakest. Last resort only.
Recovery codesTen single-use strings printed at enrollmentn/aYesMandatory backstop for every enrolled user

Why SMS is the weakest option

Three independent problems:

  • SIM swap: an attacker social-engineers the carrier into moving the victim's number to their own SIM, and every code follows.
  • Number porting and carrier account takeover do the same thing.
  • And the SS7 signaling network that routes text messages between carriers has known interception weaknesses.

NIST SP 800-63B rev 4 states that "use of the PSTN for out-of-band verification is restricted", the public switched telephone network being ordinary phone and SMS delivery, and tells verifiers to weigh risk signals such as a recent device swap, SIM change or number port before relying on it.

A mundane problem too: a rep in a concrete trade-show basement has no cell signal, so the text never arrives. Offer SMS as an alternate, never as the only factor. Cost matters as well — Supabase's Advanced MFA (Phone) add-on is $75/month for the first project and $10/month per additional project (checked July 2026), on top of your SMS provider's per-message fee.

TOTP as the baseline

TOTP (Time-based One-Time Password, RFC 6238) is what Google Authenticator, 1Password, Authy and Microsoft Authenticator generate. At enrollment the server generates a random secret and shows it as a QR code; thereafter both sides compute the same 6-digit code from that secret plus the current 30-second window. The phone needs no network, so it works in a basement, and it is free.

Its weakness is real: TOTP does not know what site you are on. If a rep lands on brandco-login.net and types her password and her code, an attacker relays both to the real site inside the 30-second window and is in. That is why passkeys matter.

Passkeys and WebAuthn as the direction of travel

WebAuthn is the browser standard behind passkeys. A passkey is a public/private key pair created by your device for one specific website. The private key never leaves the device, or the user's encrypted iCloud or Google keychain for synced passkeys. When you sign in, the site sends a challenge, the device asks you for Face ID or your PIN, signs the challenge, and returns the signature. Because the browser will only sign for the origin the key was created for, a phishing site gets nothing. That is what "phishing-resistant" means, and for a small team it is the largest practical security gain on offer.

The honest current state, as of July 2026. Supabase has shipped passkey support, but the docs label it experimental: "Passkey support is experimental. The API may change without notice. You must explicitly opt-in when creating the Supabase client." It exposes auth.registerPasskey() and auth.signInWithPasskey() plus lower-level start/verify calls, and needs @supabase/supabase-js v2.105.0 or newer.

Two limits matter for an ERP: SSO users cannot register passkeys, and passkeys are bound to the Relying Party ID, so changing your domain invalidates every one of them. Separately, Supabase's MFA API still enrolls only totp and phone factor types, so a passkey today is a primary login method rather than a second factor that raises you to aal2.

The practical reading: ship TOTP now as the enforced second factor, and offer passkeys as a convenience sign-in for staff who want them, with the expectation that the API will change.

If phishing-resistant MFA is a hard contractual requirement today, that pushes you toward Clerk, WorkOS AuthKit or Auth0 as the identity layer, with Supabase accepting their tokens through its third-party auth integration — Clerk, Firebase Auth, Auth0, AWS Cognito and WorkOS are the five supported providers (checked July 2026), and each must sign its JWTs asymmetrically and publish a kid (key id) header, which tells the verifier which public key to check the signature against.

Note also that NIST forbids syncable authenticators at AAL3 because the private key is exportable; that does not affect you, because AAL2 is the right target for an ERP.

Recovery codes

People lose phones. Without recovery codes, a lost phone means you — the founder, at 11pm — manually resetting someone's MFA, which is itself an attack vector, because a caller claiming to be a locked-out employee is the classic social engineering script. Generate ten single-use codes at enrollment, store only their hashes, show them once, and require the user to confirm they saved them. Log every recovery-code use loudly.

Enforcement policy per role

RoleMFA requirementStep-up re-auth required for
Owner, AdminMandatory, no grace periodAdding users, changing roles, rotating API keys
Accounting / ControllerMandatoryPayment runs, bank detail changes, credit memos over threshold
Sales managerMandatoryPrice list changes, discount overrides
Sales repMandatory, 7-day grace at onboarding; device-bound factor on managed iPadsNothing routine
WarehouseOptional if on a supervised, single-purpose device on the warehouse networkNothing routine
Customer service, Read-onlyRecommended; mandatory if remoteNothing routine
External rep / agentMandatory, no exceptions — they are outside your controlExporting customer lists
Retailer portal userOptional, encouragedChanging ship-to addresses, adding portal users

The warehouse exception deserves a note. Scanner terminals and shared floor tablets get handled by the device, not the person: locked-down kiosk mode, on a network segment that only reaches your app, with a short PIN to identify who is picking. Forcing a TOTP prompt on someone wearing gloves at 6am produces one shared authenticator app taped to the wall, which is worse than no MFA at all.

Session management for business users

Two clocks govern every session and you need both.

Idle timeout ends the session after a period with no activity. It protects against the unlocked laptop in an open-plan office or a hotel lobby. OWASP puts common ranges at "2-5 minutes for high-value applications and 15-30 minutes for low risk applications".

Absolute timeout ends the session a fixed time after login regardless of activity. It bounds how long a stolen credential stays useful. For an application "intended to be used by an office worker for a full day", OWASP suggests an absolute timeout "between 4 and 8 hours".

Set them per role rather than globally. The policy below has survived contact with real apparel teams.

Role / contextIdle timeoutAbsolute timeoutRemember device?
Owner, Admin30 min8 hoursYes, 14 days, MFA still required at each new session
Accounting20 min8 hoursYes, 14 days
Sales manager / rep, desktop2 hours12 hoursYes, 30 days
Sales rep, managed iPadnone (app handles lock)7 days, extended to 14 during a declared show windowDevice is the trust anchor
Warehouse shared terminal10 min10 hours (covers a shift plus overtime)No
Customer service1 hour10 hoursYes, 14 days
External rep / agent30 min8 hoursNo
Retailer B2B portal1 hour24 hoursYes, 30 days

Supabase implements both clocks natively, under Authentication → Sessions: "Time-box user sessions" is the absolute timeout, "Inactivity timeout" is the idle timeout, and "Single session per user" keeps only the most recent session. All three are Pro plan and above (checked July 2026).

One behavior to understand: Supabase does not proactively destroy sessions the moment they expire. Enforcement happens at the next refresh attempt, and expired session rows are "progressively deleted from the database 24 hours after they expire". So a client holding a still-valid access token keeps working until that token expires. With the default one-hour token, your worst-case overshoot is one hour.

Because Supabase applies those settings project-wide, per-role policy needs a small amount of your own code: a check in the Next.js proxy (the file older projects call middleware) that compares session age against the policy for the user's role and signs them out early. That is shown in step 6 of the implementation.

Refresh, and what "remember this device" should mean

Refreshing is the client trading a refresh token for a new access token, and it should be invisible. In Next.js, the proxy runs on every request and calls into the Supabase client, which refreshes if needed and writes updated cookies onto the response. Supabase rate-limits token refresh at 1,800 requests per hour per IP address, with bursts up to 30 (checked July 2026). That is comfortable for a building of tablets refreshing hourly behind one office IP, but a refresh-loop bug will hit it fast.

There is a trap in that per-IP limit for server-rendered apps. If every auth call comes from your Vercel functions, Supabase sees your server's IP, not the user's, and the whole customer base shares one bucket. Supabase's fix is IP address forwarding: enable it under Authentication → Rate Limits, then send the end-user address in an Sb-Forwarded-For header on requests made with a secret API key. Publishable and legacy keys are not supported for this.

"Remember this device" has exactly one safe meaning: skip the second factor on this device for N days. The user still types their password every single time. Implement it as a separate long-lived HttpOnly cookie holding a random device token, matched against a trusted_devices row recording the user, a hash of the token, the user agent, first- and last-seen timestamps, and an expiry. If a valid unexpired device token is present at login, let the user skip the TOTP prompt. Clear all rows for a user when their password or role changes.

Revoking on role change and offboarding

This is the part people skip and regret. Three triggers must kill access:

  • Role change. A demotion is a security event. If a sales manager becomes a sales rep, their existing JWT still carries role: sales_manager until it expires. Force the token to be reissued so the next one carries the new role.
  • Offboarding. When someone leaves, you deactivate the membership row, block their account, delete their trusted devices, revoke any API keys they created, and, this is the one that gets forgotten, invalidate any pending invitations they issued.
  • Credential change. Password change, email change, MFA factor removal.

A lot of published advice is wrong on this point, so be precise. Supabase Auth has no admin endpoint that logs a user out by user id. The POST /logout endpoint needs that user's own JWT, and auth.admin.signOut() in the JavaScript client takes a jwt argument for the same reason. What you actually have are two supported levers.

# Run this on a server, never from a browser.
# $SUPABASE_SECRET_KEY is your sb_secret_... key.

# LEVER 1 - ban the account. New sign-ins and refresh
# attempts are refused until you lift it. Reversible.
curl -X PUT \
  "https://abcdefgh.supabase.co/auth/v1/admin/users/$USER_ID" \
  -H "apikey: $SUPABASE_SECRET_KEY" \
  -H "Authorization: Bearer $SUPABASE_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ban_duration":"876000h"}'
# 876000h is about 100 years. Lift it with:
#   -d '{"ban_duration":"none"}'

# LEVER 2 - deactivate the membership row (SQL, below).
# The access token hook then mints role "none" on the
# next refresh, and every RLS policy denies.

# Expect HTTP 200 and the updated user JSON from lever 1.

Use both. Lever 1 stops the refresh token from working at all. Lever 2 makes sure that even if a token is somehow reissued, it carries a role that can read nothing. Neither one can claw back an access token that has already been issued — it stays valid until exp, which is exactly why you keep the access token lifetime at one hour rather than raising it. In the implementation section we wire this into a database trigger so it fires automatically whenever a membership row is deactivated or its role changes.

Build one habit here: after any password or email change, verify with your own eyes what your project does to other sessions. Sign in on two browsers, change the password in one, and reload the other. Do not take a blog post's word for it, including this one — the behavior is configurable and it has changed between releases.

The offline iPad, where a token must survive days

Chapter 6's PowerSync client keeps a local SQLite copy of the data the rep is allowed to see and syncs when a connection appears. SQLite is a complete database that lives in a single file on the device, with no server to run. Authentication has to bridge a gap the web never has: the device may be offline for hours or days, and it still needs a credential that PowerSync will accept when the connection returns.

PowerSync's own constraints are specific and you should design to them. The JWT must contain sub, aud, iat and exp. "The JWT must expire in 24 hours or less, and 60 minutes or less is recommended" — the gap between iat and exp must not exceed 86,400 seconds, and "JWTs older than 60 minutes are not accepted by PowerSync". Signing must use RSA, EdDSA or ECDSA verified through a JWKS endpoint; HS256 is for development only.

PowerSync's docs are explicit about why: "There is no way to revoke a JWT once issued without rotating the key."

So the token itself cannot survive days. What survives days is the refresh capability plus the local data. The correct architecture is:

OFFLINE                          BACK ONLINE
-------                          -----------
local SQLite: readable           refresh token -> new JWT (60m)
writes queue locally             JWT -> PowerSync connect
JWT expired: no sync             queued writes upload
app still fully usable           server re-checks permissions

Bounds:
  access token   60 min   (PowerSync max useful life)
  refresh token  bounded by session absolute timeout
  iPad session   7 days normal / 14 days show window
  local data     encrypted at rest by iOS + app lock

Read that as a contract. While offline the app needs no valid JWT at all, because it is reading local SQLite; it only needs one at the moment it reconnects. The risk you are bounding is "how long can a stolen iPad keep pulling fresh data", and the answer is "until the session's absolute timeout, or until you ban the account".

Bound it with four controls:

  1. The absolute session timeout on iPad sessions — 7 days, raised deliberately during a show and lowered again once the show ends.
  2. MDM: the iPad is supervised and remotely wipeable through Apple Business Manager plus your MDM tool.
  3. An app-level lock requiring Face ID after 15 minutes in the background, so a grabbed unlocked iPad is still one biometric away from your order book.
  4. Most important of all: the server re-validates every queued write on upload. When those queued orders arrive they pass through the same RLS policies as anything else, and writes from a revoked user are rejected.
Do not raise token lifetime to "fix" offline

The tempting shortcut is a 30-day JWT so the iPad never has trouble reconnecting. That hands anyone who extracts the token 30 days of unrevocable access to your customer list and pricing. PowerSync caps it at 24 hours for exactly this reason. Keep the token short, keep the refresh token as the long-lived thing, and keep the account bannable server-side.

Onboarding a customer

"Customer" here means a brand that buys your ERP, and the first hour of their life in your system sets the tone. The sequence that works:

Step 1: create the tenant and the first admin. Nobody can invite the first user because there is nobody to do the inviting. Solve it with a provisioning script you run: create the tenant row, create the first user with role owner, send them an invitation. Avoid self-serve tenant creation, because a rep signing up "to try it" ends up with a second orphan tenant containing three real orders.

Step 2: the owner invites their team. Owner enters email addresses and picks roles. The system sends invitations. This must be self-serve or you become the help desk forever.

Step 3: domain-verified auto-join, optionally. If the tenant has proven control of brandco.com, you can let anyone with a @brandco.com email join automatically at a preset default role. Prove domain control the same way every vendor does: give them a random string to publish as a DNS TXT record, then verify it.

Host:  _brandco-erp-verify.brandco.com
Type:  TXT
TTL:   300
Value: brandco-erp-verify=7c4f1a2e9b0d43a6b8e5c1f70d29a834

Check it from a terminal:
$ dig +short TXT _brandco-erp-verify.brandco.com
"brandco-erp-verify=7c4f1a2e9b0d43a6b8e5c1f70d29a834"

You generate the random value, store it against the tenant, and show the customer these exact record details. They add it at their DNS host. Your verification job runs dig or a DNS library, compares the value, and flips domain_verified_at when it matches. TTL 300 means five minutes of caching, so retries are quick.

Once verified, a new signup from that domain can auto-join. Keep auto-join at a low-privilege default role — read_only or customer_service — never at admin, because anyone who ever gets a mailbox at that domain, including a contractor, would inherit it.

Step 4: seat management. Decide early whether you charge per seat and whether the customer can add seats without talking to you. Track seats as active membership rows, not as auth users, because a user may belong to two tenants (an external agent who reps three brands). Show the owner a seat count and a limit, and block invitations that would exceed it with a clear message rather than a silent failure.

Invitation token design

The invitation token is a credential. Anyone holding it can become a user in someone else's tenant. Design it accordingly: unguessable, single-use, short-lived, bound to one email address, and stored hashed.

create extension if not exists pgcrypto;
create extension if not exists citext;

create table public.invitations (
  id             uuid primary key default gen_random_uuid(),
  tenant_id      uuid not null references public.tenants(id),
  email          citext not null,
  role           text not null
                 references public.roles(key),
  -- SHA-256 of the raw token; the raw token is never stored
  token_hash     bytea not null unique,
  invited_by     uuid not null references auth.users(id),
  expires_at     timestamptz not null
                 default now() + interval '7 days',
  accepted_at    timestamptz,
  accepted_by    uuid references auth.users(id),
  revoked_at     timestamptz,
  send_count     int not null default 0,
  last_sent_at   timestamptz,
  created_at     timestamptz not null default now()
);

-- One live invitation per email per tenant.
create unique index invitations_one_live
  on public.invitations (tenant_id, email)
  where accepted_at is null
    and revoked_at is null;

create index invitations_expiry
  on public.invitations (expires_at)
  where accepted_at is null;

-- RLS on, and deliberately NO policies: with no policy,
-- every client using the publishable key is denied. Only
-- server code holding the secret key can read this table.
alter table public.invitations enable row level security;

Walk through the choices:

  • bytea is the Postgres type for raw bytes.
  • token_hash stores SHA-256 of the token, never the token itself, so a leaked database backup does not hand over working invitations — exactly the reasoning behind hashing passwords.
  • citext is a case-insensitive text type, so Maria@ and maria@ resolve to the same invite.
  • expires_at defaults to seven days, long enough to survive a holiday, short enough that a forwarded email from March is dead.
  • accepted_at plus the unique partial index enforces single use and stops a tenant accumulating six live invites for the same person.
  • revoked_at lets an admin cancel.
  • send_count and last_sent_at let you rate-limit resends so the invite endpoint cannot be used to spam someone's inbox.

And enabling row level security with no policy at all is the strongest setting there is: nothing gets through except code holding the secret key.

import { randomBytes, createHash, timingSafeEqual }
  from 'node:crypto'

// PostgREST wants a bytea value as a hex literal string.
// Passing a raw Buffer silently stores the wrong bytes.
function toBytea(buf: Buffer) {
  return '\\x' + buf.toString('hex')
}

// 32 random bytes = 256 bits of entropy. base64url so it
// survives being pasted into a URL and an email client.
export function newInviteToken() {
  const raw = randomBytes(32).toString('base64url')
  const hash = createHash('sha256').update(raw).digest()
  return { raw, hash: toBytea(hash) }  // store hash, mail raw
}

export function hashToken(raw: string) {
  return toBytea(createHash('sha256').update(raw).digest())
}

// Used for the trusted-device cookie, where you fetch the
// row first and then compare. Constant time, so an attacker
// cannot narrow a value down by measuring how long it takes.
export function tokensMatch(a: Buffer, b: Buffer) {
  return a.length === b.length && timingSafeEqual(a, b)
}

randomBytes(32) uses the operating system's cryptographically secure random source. Do not use Math.random(), which is predictable and has been the root cause of real account-takeover bugs.

256 bits is far beyond the 64 bits of entropy OWASP requires for session identifiers, and it costs nothing. base64url encoding avoids the + and / characters that break in URLs.

The toBytea helper is the detail that bites people: the Supabase JavaScript client serializes your value to JSON, and a Buffer turns into an object, not bytes, so the row you insert will never match the row you look up. Convert to \x hex on both sides. (PostgREST is the service Supabase runs in front of Postgres to turn your tables into a web API; it is what the JavaScript client is actually talking to.) The raw token goes into the emailed link and is never written to your database or your logs.

// app/api/invitations/route.ts  (create an invitation)
import { createAdminClient } from '@/lib/supabase/admin'
import { newInviteToken } from '@/lib/invite-token'
import { sendInviteEmail } from '@/lib/email'

export async function POST(req: Request) {
  const actor = await requireRole(req, ['owner', 'admin'])
  const { email, role } = await req.json()

  if (typeof email !== 'string' || !email.includes('@')) {
    return Response.json({ error: 'email required' },
                         { status: 400 })
  }
  if (!canGrant(actor.role, role)) {
    return Response.json(
      { error: 'cannot grant a role above your own' },
      { status: 403 })
  }

  const seats = await countActiveMembers(actor.tenantId)
  if (seats.used >= seats.limit) {
    return Response.json(
      { error: 'seat limit reached', seats }, { status: 409 })
  }

  const { raw, hash } = newInviteToken()
  const admin = createAdminClient()

  const { data, error } = await admin
    .from('invitations')
    .insert({
      tenant_id:  actor.tenantId,
      email:      email.toLowerCase().trim(),
      role,
      token_hash: hash,
      invited_by: actor.userId,
      send_count: 1,
      last_sent_at: new Date().toISOString(),
    })
    .select('id, expires_at')
    .single()

  if (error) return Response.json({ error: error.message },
                                  { status: 400 })

  await sendInviteEmail({
    to: email,
    url: `${process.env.APP_URL}/invite/${raw}`,
    tenantName: actor.tenantName,
    inviterName: actor.displayName,
    expiresAt: data.expires_at,
  })

  await audit('invitation.created', actor, {
    invitation_id: data.id, email, role })

  return Response.json({ id: data.id }, { status: 201 })
}

Line by line:

  • we require the caller to be an owner or admin;
  • we sanity-check the email;
  • canGrant stops an admin from minting an owner, which is privilege escalation;
  • we check the seat limit before creating anything so the customer gets a clear 409 rather than a surprise invoice;
  • we generate the token and store only the hash;
  • we email the raw token in a URL;
  • and we write an audit record.

Notice the admin client — it uses the Supabase secret key and bypasses RLS, so it must only ever run server-side, and every path into it must do its own permission check first.

// app/api/invitations/accept/route.ts
export async function POST(req: Request) {
  const { token, password, fullName } = await req.json()
  const admin = createAdminClient()

  // One generic error for every failure case, so the
  // endpoint cannot be used to probe for valid tokens.
  const bad = () => Response.json(
    { error: 'This invitation is no longer valid.' },
    { status: 400 })

  if (typeof token !== 'string' || token.length < 20) {
    return bad()
  }
  if (typeof password !== 'string' || password.length < 12) {
    return Response.json(
      { error: 'Password must be at least 12 characters.' },
      { status: 400 })
  }

  const { data: inv } = await admin
    .from('invitations')
    .select('*')
    .eq('token_hash', hashToken(token))
    .maybeSingle()

  if (!inv) return bad()
  if (inv.accepted_at) return bad()
  if (inv.revoked_at) return bad()
  if (new Date(inv.expires_at) < new Date()) return bad()

  // The invitee may already have an account - an outside
  // agent who reps three brands signs in once and belongs
  // to three tenants. Reuse the account, do not fail.
  let userId = await findUserIdByEmail(admin, inv.email)
  let didCreate = false

  if (!userId) {
    const { data: created, error: cErr } =
      await admin.auth.admin.createUser({
        email: inv.email,
        password,
        email_confirm: true,     // the invite proved the email
        user_metadata: { full_name: fullName },
      })
    if (cErr || !created?.user) return bad()
    userId = created.user.id
    didCreate = true
  }

  const { error: mErr } = await admin.from('memberships')
    .insert({ tenant_id: inv.tenant_id,
              user_id: userId,
              role: inv.role, status: 'active' })
  if (mErr) {
    if (didCreate) {
      await admin.auth.admin.deleteUser(userId)
    }
    return bad()
  }

  const { data: claimed } = await admin.from('invitations')
    .update({ accepted_at: new Date().toISOString(),
              accepted_by: userId })
    .eq('id', inv.id)
    .is('accepted_at', null)      // idempotency guard
    .select('id')

  if (!claimed?.length) return bad()  // someone beat us to it

  await audit('invitation.accepted',
              { userId, tenantId: inv.tenant_id },
              { invitation_id: inv.id })

  return Response.json({ ok: true })
}

The important details here are the ones that are easy to skip. Every failure returns the same message. If you distinguish "expired" from "not found", the endpoint answers a question an attacker wants answered, which tokens exist, and guessing becomes worthwhile. The password minimum is enforced on the server, not just in the form, because the form is not the only way to call this.

The account is created with email_confirm: true because possession of the invitation already proves control of the mailbox; making them confirm again is friction with no benefit. Note that tenant_id is deliberately not written into app_metadata here — the membership row is the single source of truth, and step 5's access token hook reads it fresh on every token.

If the membership insert fails and we created the auth user in this request, we delete it so we do not strand an account with no tenant; if the user already existed, we leave their account alone. The final update carries .is('accepted_at', null) and checks that a row actually came back, so two racing submissions cannot both succeed. That is what the "idempotency guard" comment means: running the request twice leaves the system in the same state as running it once.

When an employee leaves

Write this as a single function and a single button, because a checklist that a human runs will be run partially.

create or replace function public.offboard_member(
  p_tenant_id uuid,
  p_user_id   uuid,
  p_actor_id  uuid,
  p_reassign_to uuid default null
) returns void
language plpgsql
security definer
set search_path = public
as $$
begin
  -- SECURITY DEFINER runs as the function owner, so this
  -- check is the only thing standing between a caller and
  -- everyone else's account. Never remove it.
  if not exists (
    select 1 from memberships a
     where a.tenant_id = p_tenant_id
       and a.user_id   = p_actor_id
       and a.status    = 'active'
       and a.role in ('owner','admin')
  ) then
    raise exception 'not authorised to offboard members';
  end if;

  update memberships
     set status = 'inactive',
         deactivated_at = now(),
         deactivated_by = p_actor_id
   where tenant_id = p_tenant_id
     and user_id = p_user_id;

  update trusted_devices set revoked_at = now()
   where user_id = p_user_id and revoked_at is null;

  update api_keys set revoked_at = now(),
                      revoked_by = p_actor_id
   where tenant_id = p_tenant_id
     and created_by = p_user_id and revoked_at is null;

  update invitations set revoked_at = now()
   where tenant_id = p_tenant_id
     and invited_by = p_user_id
     and accepted_at is null and revoked_at is null;

  if p_reassign_to is not null then
    update customers set owner_user_id = p_reassign_to
     where tenant_id = p_tenant_id
       and owner_user_id = p_user_id;
  end if;

  insert into auth_events(tenant_id, actor_id, subject_id,
                          event, detail)
  values (p_tenant_id, p_actor_id, p_user_id,
          'member.offboarded',
          jsonb_build_object('reassigned_to', p_reassign_to));

  -- Postgres cannot make HTTP calls. Queue the ban for a
  -- worker (pg-boss, chapter 9) to send to the Admin API.
  insert into job_queue(kind, payload)
  values ('ban_user',
          jsonb_build_object('user_id', p_user_id));
end $$;

-- Lock the function down. Without these two lines any
-- signed-in user could call it over RPC.
revoke all on function public.offboard_member(
  uuid, uuid, uuid, uuid) from public, anon, authenticated;
grant execute on function public.offboard_member(
  uuid, uuid, uuid, uuid) to service_role;

One transaction checks that the caller is actually an owner or admin of that tenant, deactivates the membership, kills trusted devices so the "remember me" cookie stops working, revokes any API keys they created, cancels invitations they had outstanding, optionally reassigns their accounts to another rep so orders do not go dark, and writes the audit row. The queued job is the hand-off: a worker picks it up and calls the Admin API to ban the account, which is the part Postgres cannot do itself.

Note the deliberate absence of a delete — you never delete a user who has touched orders, because the ledger from chapter 1 references them forever. Deactivate, never delete.

And note the revoke/grant pair at the bottom: a security definer function runs with its owner's privileges, so leaving it callable by authenticated would let any signed-in user offboard anybody. Supabase publishes every function in the public schema at an HTTP endpoint — that is what RPC, remote procedure call, means here, so "nobody would call it" is not a defense. Grants are.

Roles and permissions in practice

Roles are a compression scheme. Rather than assigning 200 individual permissions to each of 40 people, you define nine roles, assign permissions to roles, and assign one role per person per tenant. Here is a matrix that fits an apparel wholesale brand.

Four abbreviations recur in it:

  • ATS means "available-to-sell", the quantity of a style and size you can still promise a buyer after existing orders are subtracted.
  • PII means personally identifiable information, such as a buyer's direct phone number.
  • ASN means advance ship notice, the electronic packing list a retailer expects before your delivery arrives at their door.
  • GL means general ledger, the accounting book of record from chapter 1, so "GL mapping" is the rule that decides which ledger account a transaction lands in.
RoleCan seeCan changeCannotRow scope
OwnerEverything, including cost sheets, margin, payroll-adjacent data, billingEverything, including roles and billingWhole tenant
AdminEverything except billingUsers, roles below owner, integrations, API keys, settingsGrant or remove owner; change billingWhole tenant
Sales managerAll customers, all orders, all reps' numbers, price lists, ATS, margin by stylePrice lists, discounts within policy, order approvals, rep-to-account assignmentEdit cost sheets, post payments, change GL mappingWhole tenant
Sales repOwn accounts, own orders, ATS, linesheets, own commissionCreate and edit own orders while status is draft or openSee other reps' accounts, see cost or margin, change prices beyond allowed discount bandOnly rows where they are the assigned rep
Customer serviceAll customers and orders, shipping status, returnsShip-to addresses, order notes, RA (return authorization) creation, split shipmentsChange prices, release credits, see costWhole tenant, read-heavy
WarehousePick tickets, allocations, inventory, ASN and carton dataPick, pack, ship confirm, inventory adjustments with reason codesSee prices, customers' credit status, or marginWhole tenant, inventory domain only
AccountingInvoices, payments, credits, chargebacks, aging, cost, marginPost payments, issue credit memos, resolve chargebacks, credit limitsCreate or edit sales orders; change ATSWhole tenant, finance domain
Read-onlyOrders, inventory, shipping — no cost, no margin, no customer contact PII beyond companyNothingAny write at allWhole tenant or scoped
External rep / agentOwn accounts only, own orders, ATS, linesheets, own commissionCreate and edit own draft ordersExport customer lists, see other agents, see cost, see any tenant-wide reportOwn accounts, plus tighter export limits than an internal rep

Two nuances that matter in this industry. First, cost is the crown jewel. FOB cost and landed cost tell a competitor or a departing rep exactly what your margin is. Cost visibility should be a separate permission, not implied by seniority — some sales managers get it, some do not, and that is a policy decision per customer.

Second, external agents are not employees. A multi-line showroom rep also sells three competing brands. They need order entry and ATS, and they should not be able to export a customer list to CSV. Make export a distinct permission.

How this composes with RLS from chapter 5

Chapter 5 established that every table carries tenant_id and that RLS policies filter on the tenant claim in the JWT. Roles sit inside tenant isolation as an inner layer. The composition is:

          request
             |
   [ tenant isolation ]   RLS: tenant_id = jwt tenant_id
             |            -- never bypassable, no exceptions
   [ role gate         ]  RLS: role in (allowed roles)
             |            -- what kind of user may touch this
   [ row scope         ]  RLS: owner_user_id = auth.uid()
             |            -- which rows within their tenant
   [ column policy     ]  views / column grants
             |            -- cost and margin hidden per role
   [ app-level UX      ]  hide buttons they cannot use
             |            -- convenience only, not security
          response

Read this top to bottom as layers of a sieve. Tenant isolation is absolute and is never relaxed for any role — even an owner cannot see another tenant. The role gate narrows by user type. Row scope narrows further, and this is where "a rep sees only their own accounts" lives. Column policy handles cost and margin, because hiding a column is different from hiding a row and RLS alone does not do it — you use a restricted view or column-level grants.

The app layer at the bottom only hides buttons. Treat that as a courtesy to the user and nothing more, because anyone can call the API directly and skip your interface entirely.

-- Helper functions read claims once, cheaply.
create or replace function public.jwt_tenant_id()
returns uuid language sql stable as $$
  select nullif(
    ((select auth.jwt()) -> 'app_metadata' ->> 'tenant_id'),
    '')::uuid
$$;

create or replace function public.jwt_role()
returns text language sql stable as $$
  select coalesce(
    ((select auth.jwt()) -> 'app_metadata' ->> 'role'),
    'none')
$$;

create or replace function public.jwt_aal()
returns text language sql stable as $$
  select coalesce((select auth.jwt()) ->> 'aal', 'aal1')
$$;

-- ORDERS: who may read and write which rows.
alter table public.orders enable row level security;

create policy orders_tenant_isolation
  on public.orders as restrictive
  for all to authenticated
  using (tenant_id = public.jwt_tenant_id());

create policy orders_read_scope
  on public.orders for select to authenticated
  using (
    case public.jwt_role()
      when 'owner'            then true
      when 'admin'            then true
      when 'sales_manager'    then true
      when 'customer_service' then true
      when 'accounting'       then true
      when 'warehouse'        then status in
             ('allocated','picking','packed','shipped')
      when 'read_only'        then true
      when 'sales_rep'        then rep_user_id = auth.uid()
      when 'external_agent'   then rep_user_id = auth.uid()
      else false
    end
  );

create policy orders_write_scope
  on public.orders for update to authenticated
  using (
    case public.jwt_role()
      when 'owner'          then true
      when 'admin'          then true
      when 'sales_manager'  then true
      when 'sales_rep'      then rep_user_id = auth.uid()
                                and status in ('draft','open')
      when 'external_agent' then rep_user_id = auth.uid()
                                and status = 'draft'
      else false
    end
  )
  -- WITH CHECK is applied to the row AFTER the update.
  -- Without it a rep could hand their order to someone
  -- else, or push it into a status they cannot touch.
  with check (
    case public.jwt_role()
      when 'owner'          then true
      when 'admin'          then true
      when 'sales_manager'  then true
      when 'sales_rep'      then rep_user_id = auth.uid()
                                and status in ('draft','open')
      when 'external_agent' then rep_user_id = auth.uid()
                                and status = 'draft'
      else false
    end
  );

-- Privileged finance tables additionally require MFA.
alter table public.payments enable row level security;

create policy payments_require_mfa
  on public.payments as restrictive
  for all to authenticated
  using (public.jwt_aal() = 'aal2');

The first three functions pull claims out of the verified JWT. Marking them stable lets Postgres call them once per statement rather than once per row, which matters on a 40,000-row order table. Wrapping auth.jwt() in (select ...) is the documented pattern that lets the planner cache the result.

The tenant policy is declared as restrictive, which means it is ANDed with everything else — no later permissive policy can widen past it. The read policy is a single case over the role claim, which reads like the matrix table above and is easy to review in a code review.

Note the two row-scoped roles at the bottom: sales_rep and external_agent only match rows where rep_user_id equals their own user id. That single line is "a rep sees only their own accounts", enforced by the database, applying equally to the web app, the iPad, a CSV export, and a mistake in a background job.

The write policy is deliberately narrower than the read policy: a rep can read an order in any status but can only edit it while it is draft or open. It carries both a using clause, which decides which existing rows may be targeted, and a with check clause, which decides whether the row is still allowed after the edit.

Leaving with check off is a common and expensive mistake: a rep could issue an update that sets rep_user_id to a colleague, and the database would allow it.

The final policy is the MFA gate — payments are unreachable unless the token says a second factor was verified, and because it is restrictive it cannot be bypassed by any other policy.

One gap to close in your own migration: the policies above cover reading and updating. Creating an order needs its own for insert ... with check (...) policy with the same role logic, or a rep will be able to read and edit orders and never create one.

Keep the role list short and boring

Every customer will ask for a custom role in month three. Resist until you have three customers asking for the same one. Nine roles you can hold in your head beat forty granular permissions you cannot audit. When you genuinely need finer control, add named permissions (can_view_cost, can_export) as boolean flags on the membership row and check those in policies, rather than multiplying roles.

Customer-facing access: the B2B portal

Most brands doing meaningful volume should give retailers logins. A portal that lets a buyer at a boutique see order status, download invoices, check ATS and reorder cuts a large share of your customer-service email. But the account model must be genuinely separate from staff, in four ways.

Four ways a portal account differs

Different identity space. A portal user belongs to a customer account, not to your tenant's staff directory. Model it as its own table with its own membership: portal_users linked to customers. Do not put a buyer at Nordstrom into your memberships table with a special role, because one mistaken policy and they are inside your staff surface.

Different default posture. Staff are default-deny with grants; portal users are default-deny with a very small allowlist: their own account's orders, their own invoices, published ATS and linesheets, their own shipping documents. Nothing else exists as far as their queries are concerned.

Different lifecycle. Retail buyers churn constantly and nobody tells you. Expect stale accounts. Auto-deactivate portal users after 12 months of no logins and email them a re-activation link. Let the retailer's own primary contact manage their colleagues' access, so you are not the help desk for someone else's staff turnover.

Different login method. Magic links or emailed codes earn their keep here. A buyer logs in four times a year. They will not remember a password, they will not install an authenticator app, and forcing either will kill adoption. Emailed one-time codes give a decent experience with no password to reset.

-- Portal users are scoped to a customer, not to staff.
create table public.portal_users (
  user_id     uuid primary key references auth.users(id),
  tenant_id   uuid not null references public.tenants(id),
  customer_id uuid not null references public.customers(id),
  role        text not null default 'buyer'
              check (role in ('buyer_admin','buyer','viewer')),
  status      text not null default 'active'
              check (status in ('active','inactive')),
  last_seen_at timestamptz,
  created_at  timestamptz not null default now(),
  unique (tenant_id, user_id)
);

alter table public.portal_users enable row level security;

create policy portal_users_read_self
  on public.portal_users for select to authenticated
  using (user_id = auth.uid());

create or replace function public.jwt_customer_id()
returns uuid language sql stable as $$
  select nullif(((select auth.jwt()) -> 'app_metadata'
                 ->> 'customer_id'), '')::uuid
$$;

-- Portal read access to orders: own customer only.
create policy orders_portal_read
  on public.orders for select to authenticated
  using (
    public.jwt_role() = 'portal'
    and tenant_id = public.jwt_tenant_id()
    and customer_id = public.jwt_customer_id()
    and status <> 'draft'
  );

The portal user carries two extra claims: role = 'portal' and a customer_id. The policy requires both to match, so a Nordstrom buyer sees Nordstrom orders and nothing else, and the status <> 'draft' clause stops them seeing an order your rep is still building.

Because the claims come from the signed JWT, and the JWT is minted by the access token hook in step 5 reading the portal_users table, the buyer cannot alter them. Note that portal deliberately does not appear in the staff roles table, so the staff policies above fall through to else false for a portal user.

Service accounts and API keys

Chapter 4's integrations need to authenticate too, and none of them can type a password or hold a phone. EDI (electronic data interchange, the fixed-format purchase-order and invoice files big retailers exchange), the 3PL's webhook (third-party logistics — the warehouse company that ships for you), the Shopify sync, the accounting export.

The mistake to avoid is issuing them a human user account: a service running as "maria@brandco.com" is invisible in audit logs, breaks when Maria leaves, and inherits every permission Maria has. Create explicit service accounts instead, with their own identity, their own scopes, and no ability to log in through the UI.

create table public.api_keys (
  id           uuid primary key default gen_random_uuid(),
  tenant_id    uuid not null references public.tenants(id),
  name         text not null,             -- 'ShipBob webhook'
  prefix       text not null,             -- 'ak_live_7fQ2'
  key_hash     bytea not null unique,     -- sha256(full key)
  scopes       text[] not null default '{}',
  created_by   uuid not null references auth.users(id),
  created_at   timestamptz not null default now(),
  expires_at   timestamptz,
  last_used_at timestamptz,
  last_used_ip inet,
  revoked_at   timestamptz,
  revoked_by   uuid references auth.users(id)
);

create index api_keys_lookup on public.api_keys (prefix)
  where revoked_at is null;

-- RLS on, no policies: server-side code only.
alter table public.api_keys enable row level security;

-- Scope vocabulary: verb:resource, no wildcards.
--   orders:read      orders:write
--   inventory:read   inventory:write
--   invoices:read    shipments:write
--   customers:read   webhooks:receive

The key you hand out looks like ak_live_7fQ2_9d3c1a8b....

  • The prefix column stores only the first segment, which lets you look the key up with an index and lets a human recognize it in a config file without exposing the secret.
  • key_hash stores SHA-256 of the whole key, so your database never contains a usable credential — same reasoning as the invitation token, and the same \x hex conversion applies when you write it.
  • scopes is an explicit array with no wildcard, so a 3PL webhook key that only needs to post shipment confirmations gets exactly shipments:write and nothing else.
  • last_used_at and last_used_ip let you find keys nobody uses and revoke them, and tell you fast whether a leaked key is being exercised from somewhere unexpected.
  • expires_at forces rotation to be a scheduled event rather than a crisis.

Rotation without downtime

Rotation means replacing a key while the integration keeps running. The pattern is always the same, and it applies to JWT signing keys, API keys and webhook secrets alike, so learn it once:

1. Issue key B alongside key A. Both valid.
2. Update the consumer's config to use B.
3. Watch api_keys.last_used_at for A. When it stops
   moving for a full cycle of that integration
   (24h for a nightly job, 1h for a webhook), proceed.
4. Revoke A. Set revoked_at, do not delete the row.
5. Log the rotation as an auth event.

Same shape for Supabase JWT signing keys:
  create standby key
  -> wait ~20 min for JWKS caches to clear
  -> rotate (standby becomes active)
  -> wait token-expiry + 15 min  (1h15m at a 1h token)
  -> revoke the previous key

Step 3 is the one people skip, and skipping it is how integrations break at 2am. The last_used_at column exists specifically so you can answer "is anything still using the old key?" with data instead of hope.

The signing-key timings come from Supabase's own guidance (checked July 2026): the JWKS endpoint is cached about 10 minutes at the edge and another 10 minutes inside the client library, hence the 20-minute wait after creating a standby key; and before revoking a previously-used key you "wait for your configured access token expiry time plus 15 minutes", so one hour and fifteen minutes at the default token life. Revoke sooner than that and you sign out everyone still holding a token signed by the old key.

Two more rules. Never put an API key in the front-end bundle or in a git repository — use environment variables, and run a secret scanner such as gitleaks in CI (continuous integration: the automated checks that run on every push before code can merge).

And distinguish Supabase's own two key types: publishable keys (sb_publishable_...) run through the anon and authenticated Postgres roles and respect RLS; secret keys (sb_secret_...) use the service_role Postgres role, skip "any and all Row Level Security policies", and are refused with HTTP 401 when the request looks like it came from a browser. Supabase's API keys documentation says the legacy anon and service_role JWT keys "will be deprecated by the end of 2026" (checked July 2026), so start new work on the new format.

The secret key bypasses every policy you wrote

Any code path holding sb_secret_... runs as service_role and RLS does not apply. That is why invitation acceptance, offboarding and impersonation in this chapter all do their own explicit permission check first. Treat every function that constructs an admin client as security-critical code and review it as such. The same warning covers security definer SQL functions: they run as their owner, so each one needs its own authorization check and its own revoke ... from authenticated.

Account security operations

Password reset done safely

The reset flow has four requirements:

  • The response must be identical whether or not the email exists, or you have built an account-enumeration tool.
  • The reset token must be single-use and short-lived.
  • Using it must invalidate existing sessions.
  • And the user must be told, at their old address, that a reset happened.
// Step 1: request a reset. Always returns the same thing.
export async function POST(req: Request) {
  const { email } = await req.json()
  const supabase = await createClient()

  await supabase.auth.resetPasswordForEmail(email, {
    redirectTo: `${process.env.APP_URL}/auth/confirm` +
                `?next=/account/new-password`,
  })

  // Deliberately ignore the result. Same response either way.
  return Response.json({
    message: 'If that address has an account, ' +
             'we have sent a reset link.',
  })
}

Ignoring the result is the point. If you returned "no such user" for unknown addresses, anyone could paste in a list of emails and learn which ones are your customers' staff. Supabase's reset emails carry the same one-hour, one-time-use token as its magic links, and a user may only request one every 60 seconds (checked July 2026).

The redirectTo value must match an entry on your Redirect URLs allowlist in the Supabase dashboard. Miss that and the link drops the user at your Site URL — the default destination when no usable redirectTo is supplied — with no error to explain it, and the user reports the reset as broken. It is one of the most common Supabase auth support tickets.

// Step 2: the /auth/confirm route exchanges the token hash
// for a session (PKCE flow), then sends the user onward.
import { type EmailOtpType } from '@supabase/supabase-js'
import { redirect } from 'next/navigation'

export async function GET(request: Request) {
  const url = new URL(request.url)
  const token_hash = url.searchParams.get('token_hash')
  const type = url.searchParams.get('type') as EmailOtpType
  const raw = url.searchParams.get('next') ?? '/'

  // Only allow same-site relative paths, or this route is
  // an open redirect that phishers will point at your users.
  const next = raw.startsWith('/') && !raw.startsWith('//')
               ? raw : '/'

  if (!token_hash || !type) redirect('/auth/error?e=missing')

  const supabase = await createClient()
  const { error } = await supabase.auth.verifyOtp({
    type, token_hash })

  if (error) redirect('/auth/error?e=invalid')
  redirect(next)
}

This route is what your email template links to when you use {{ .TokenHash }} instead of {{ .ConfirmationURL }}. It swaps the hash for a real session and sets the cookies, then forwards to wherever the flow was heading. The same route serves signup confirmation, magic links, email change and reset, distinguished by the type parameter — one route, four flows.

The next check earns its place. Any route that redirects to a user-supplied URL is an open redirect, and an attacker will mail your customers a link that starts on your real domain and lands on theirs.

After the new password is set, email a notification to the address on file, and check with your own two-browser test what happened to the other sessions. Sending the "your password was changed" email is your job either way, and it is how a compromised account gets caught by its owner within minutes.

Email change verification

An attacker with a live session who can change the email address owns the account permanently, because every future reset goes to them. The defense is confirming both addresses. Supabase does this when "Secure email change" is enabled: it sends a confirmation to the current address and the new one, and the change only completes when both are confirmed. Turn it on.

Then require re-authentication before the change: call supabase.auth.reauthenticate(), which sends a one-time code to the address currently on file, and pass that value as the nonce argument to updateUser. A nonce is a value that works once and then stops working. Log the old and new values in your audit trail.

Lockout and rate limiting

Rate limiting protects three different things and they need different limits: credential guessing against one account; credential stuffing across many accounts from one source, which means replaying email-and-password pairs leaked from some other website; and email or SMS flooding that costs you money and reputation. The Supabase column below is copied from the rate limits reference and was checked in July 2026.

EndpointSupabase default (July 2026)What to add yourself
Password sign-in (/token)Not separately limited; shares the token bucket with refreshPer-account: exponential backoff after 5 failures, cap at 15 min. Per-IP: 20 attempts / 10 min.
Token refresh1,800 / hour per IP, bursts to 30Alert if any single IP approaches it — usually a refresh loop bug. Turn on IP forwarding for server-rendered apps.
Token verification (/verify)360 / hour per IP, bursts to 30
OTP / magic link30 OTPs / hour project-wide; 60-second window per userRaise the project limit once on custom SMTP; per-email cap of 5/hour
Signup confirmation, password reset60-second window per userSurface the wait in the UI so users stop hammering the button
MFA challenge / verify15 / hour per IP, with burstsLock the factor after 10 bad codes; require a recovery code
Built-in email sending2 emails per hour, project-wideConfigure custom SMTP before launch — this default will block real onboarding
Anonymous sign-ins30 / hour per IP, bursts to 30Disable entirely unless you need it

Supabase uses a token bucket for the IP-limited endpoints: each bucket holds 30 requests, so an idle client tolerates a short spike, and sustained traffic above the rate is refused with HTTP 429.

Prefer exponential backoff over hard lockout in your own layer. A hard "locked for 30 minutes after 5 failures" rule is itself a denial-of-service tool: an attacker who knows your controller's email can lock them out during month-end close by failing five logins. Backoff with a cap slows an attacker to uselessness while a legitimate user who fixes their typo waits seconds.

Combine it with leaked-password checking so the passwords being guessed are not in the breach corpus to begin with, and with a CAPTCHA (Supabase supports Cloudflare Turnstile and hCaptcha) triggered only after repeated failures rather than on every login.

You will need to see what a user sees. "It says my order is missing" is unanswerable without it. Casual impersonation is a backdoor. The version worth building is logged, consented, time-bounded and impossible to miss on screen.

// POST /api/support/impersonate
export async function POST(req: Request) {
  const actor = await requireRole(req, ['owner', 'admin'])
  const { targetUserId, reason, ticketRef } = await req.json()

  if (!reason || reason.length < 20) {
    return Response.json(
      { error: 'A written reason is required.' },
      { status: 400 })
  }

  // Consent must already exist and be unexpired.
  const consent = await getActiveConsent(targetUserId)
  if (!consent) {
    await notifyUser(targetUserId, 'impersonation_requested')
    return Response.json(
      { error: 'awaiting user consent' }, { status: 409 })
  }

  const session = await createImpersonationSession({
    actorId: actor.userId,
    targetUserId,
    tenantId: actor.tenantId,
    expiresAt: new Date(Date.now() + 30 * 60_000),
    readOnly: true,          // writes blocked by default
  })

  await audit('support.impersonation.started', actor, {
    target_user_id: targetUserId, reason, ticketRef,
    consent_id: consent.id, expires_at: session.expiresAt })

  await notifyUser(targetUserId,
    'impersonation_started', { actor: actor.displayName })

  return Response.json({ token: session.token })
}

Six controls in one handler:

  • A written reason of real length, so the audit log is useful six months later.
  • Explicit prior consent from the user, which they grant in their own settings and which expires.
  • A 30-minute hard expiry.
  • Read-only by default, so support can look without accidentally changing an order.
  • An audit event containing actor, target, reason and ticket reference.
  • And a notification to the user that it is happening.

In the UI, an impersonated session must show a permanent, loud banner, a colored bar across the top reading "Viewing as Maria Chen, read only — 24 minutes left, End session", because the worst impersonation incident is the one where the support person forgot they were in it.

The impersonation token should carry distinct claims: act (the real actor's id) alongside sub (the impersonated user). Then every audit record written during the session automatically shows both, and your RLS policies can refuse writes when act is present. Mint that token from your own signing key in your own service; do not try to make Supabase issue a session for a user you are not.

Breach response

Decide this before you need it, because you will decide badly at 3am. Write a one-page runbook covering the four scenarios that actually happen:

One user's credentials are compromised. Ban the account, force a password reset, revoke their trusted devices, review their audit trail for the last 30 days for exports and permission changes, notify the tenant's owner, then lift the ban once they are back on a clean device.

A secret key leaked (committed to git, pasted in Slack). Create a new secret key, deploy it everywhere, delete the old one in the Supabase dashboard, deletion is irreversible, then review database logs for access from unexpected IPs during the exposure window. Assume every row was readable and act accordingly.

Your JWT signing key is suspected compromised. Create a standby key, rotate, and revoke the previous key immediately rather than waiting the usual hour and fifteen minutes; accept that everyone gets signed out. Supabase's signing-key documentation permits immediate revocation during an active security incident, which is exactly this case.

A customer's identity provider is compromised. Disable that SSO connection, which forces their users to your fallback method, and coordinate with their IT before re-enabling.

In all four cases: preserve logs before changing anything, write the timeline as you go, and tell the affected customer the same day. Depending on where your customer and their staff are, you may have statutory notification deadlines — chapter M covers the legal side. Your job here is to make sure the technical evidence exists.

Auditing authentication events

Chapter 5 covers the SOC 2 posture — SOC 2 being the security audit report that enterprise customers ask for before they will sign. This section is about the specific evidence auth must produce. The controls an auditor asks about — logical access, provisioning, deprovisioning, privileged access review — are all questions your auth logs must answer without you doing archaeology.

Log every one of these, as structured JSON, into a table you own:

EventMust captureWhy an auditor cares
auth.login.successuser, tenant, method, aal, ip, user agent, device idProves access is authenticated and traceable
auth.login.failureattempted email (hashed if you prefer), ip, reason classDetects brute force; shows monitoring exists
auth.mfa.enrolled / .removed / .recovery_useduser, factor type, actorMFA policy enforcement evidence
auth.logout and auth.session.revokeduser, session id, trigger (manual / role change / offboard)Deprovisioning is timely
member.invited / .accepted / .role_changed / .offboardedactor, subject, old role, new role, timestampAccess provisioning is authorized and reviewed
apikey.created / .rotated / .revokedactor, key prefix, scopesService account governance
support.impersonation.started / .endedactor, target, reason, consent, durationPrivileged access is controlled and consented
auth.password.changed / .reset / email.changeduser, actor, old and new emailAccount takeover detection
create table public.auth_events (
  id          bigint generated always as identity primary key,
  occurred_at timestamptz not null default now(),
  tenant_id   uuid,
  actor_id    uuid,        -- who did it
  subject_id  uuid,        -- who it was done to
  event       text not null,
  ip          inet,
  user_agent  text,
  session_id  uuid,
  detail      jsonb not null default '{}'::jsonb
);

create index auth_events_tenant_time
  on public.auth_events (tenant_id, occurred_at desc);
create index auth_events_subject_time
  on public.auth_events (subject_id, occurred_at desc);
create index auth_events_event_time
  on public.auth_events (event, occurred_at desc);

-- Append-only for clients: no inserts, updates or deletes.
-- Rows arrive from triggers and from the secret-key client.
revoke insert, update, delete on public.auth_events
  from authenticated, anon;

alter table public.auth_events enable row level security;

create policy auth_events_read
  on public.auth_events for select to authenticated
  using (tenant_id = public.jwt_tenant_id()
         and public.jwt_role() in ('owner','admin'));

Separating actor_id from subject_id is what lets you answer both "what did this admin do?" and "what was done to this user?" from the same table. Two column types need a word of explanation: inet is Postgres's type for an IP address, and jsonb is its binary JSON type, which stores a whole JSON object in one column and can be indexed.

The three indexes cover the three questions you will actually ask. Revoking insert, update and delete from the client roles makes the table append-only from the outside, in the same spirit as chapter 1's ledger — an audit log that can be edited is not evidence. RLS then limits reads to owners and admins within the tenant, so a rep cannot browse the login history of the whole company.

Two caveats to be honest about: the service_role key still bypasses all of this, and a database superuser can still edit rows, so if your auditor wants true immutability you ship a copy to append-only object storage as well.

Retention and what not to log

On retention: Supabase's platform log retention is 1 day on Free, 7 days on Pro, 28 days on Team and 90 days on Enterprise (checked July 2026), which is fine for debugging and useless for compliance. Keep your own auth_events table for 12 months hot in Postgres, then archive older rows to object storage as compressed JSONL (one JSON object per line, which is easy to append to and easy to grep) on a monthly job.

Twelve months hot covers a SOC 2 Type II observation window — Type II audits watch your controls over a stretch of time, commonly three to twelve months, and any "who changed this price in March" question. Do not log passwords, tokens, or full session identifiers — OWASP recommends logging "a salted-hash of the session ID instead of the session ID itself in order to allow for session-specific log correlation without exposing the session ID".

Log the failures as carefully as the successes. A spike in auth.login.failure for one account is an attack; a spike in permission denials for one user is either a misconfigured role or someone probing. Alert on rates, not on individual events, or you will mute the channel within a week.

A complete worked implementation with Supabase Auth

What follows is the recommended default, end to end: email and password with mandatory TOTP for privileged roles, cookie-based sessions via @supabase/ssr, custom claims through the access token hook, and RLS doing the enforcement. Commands and dashboard steps were checked in July 2026. Dashboard layouts change; where a step depends on the UI, the concept and the search term are given.

Step 1: dependencies and environment

npm install @supabase/supabase-js @supabase/ssr

# Keep secrets out of git BEFORE you write any.
touch .gitignore
grep -qxF '.env.local' .gitignore \
  || echo '.env.local' >> .gitignore

# Create the file only if it does not already exist, so
# this cannot clobber keys you already pasted in.
if [ -e .env.local ]; then
  echo ".env.local exists - edit it by hand."
else
  umask 077
  cat > .env.local <<'EOF'
NEXT_PUBLIC_SUPABASE_URL=https://abcdefgh.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_REPLACE
SUPABASE_SECRET_KEY=sb_secret_REPLACE
APP_URL=https://erp.brandco.com
EOF
  chmod 600 .env.local
  echo "Created .env.local - paste the real keys in now."
fi

Two packages: the core client, plus the server-side rendering helper that handles cookies. The .gitignore line goes first on purpose, because the usual way a secret key reaches GitHub is that someone writes the file, commits, and adds the ignore rule afterwards. The if [ -e ... ] guard exists because cat > truncates: without it, re-running this snippet wipes the keys you already pasted in. umask 077 plus chmod 600 makes the file readable only by you.

The publishable key is safe in browser code and respects RLS. The secret key bypasses RLS and must exist only in server environment variables — in Vercel, add it under Project Settings → Environment Variables, and never give it a NEXT_PUBLIC_ prefix, because that prefix is what tells Next.js to inline the value into the JavaScript bundle it ships to every visitor.

If you are on the older key format you will see NEXT_PUBLIC_SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY instead; both still work, with Supabase targeting the end of 2026 for deprecation.

Step 2: dashboard configuration

Authentication -> URL Configuration
  Site URL:       https://erp.brandco.com
  Redirect URLs:  https://erp.brandco.com/auth/confirm
                  https://erp.brandco.com/auth/callback
                  https://*-brandco.vercel.app/auth/confirm
                  http://localhost:3000/auth/confirm

Authentication -> Sign In / Providers -> Email
  Confirm email:               ON
  Secure email change:         ON   (both addresses confirm)
  Minimum password length:     12
  Password requirements:       least restrictive option
  Leaked password protection:  ON   (Pro plan and above)

Authentication -> Sessions        (Pro plan and above)
  Time-box user sessions:      8 hours
  Inactivity timeout:          30 minutes
  Single session per user:     OFF

Authentication -> Rate Limits
  IP address forwarding:       ON if you render on a server

Authentication -> SMTP Settings
  Custom SMTP:                 ON  (see step 3)

Project Settings -> JWT Keys
  Signing algorithm:           ES256 (asymmetric)
  Access token expiry:         3600 seconds

The Redirect URLs allowlist is the setting that breaks the most first deployments: any redirectTo you pass must appear here or Supabase falls back to the Site URL with no error. Include your Vercel preview wildcard so preview deployments work.

On password requirements, Supabase's character-set choices run from "Digits only" up to "Digits, lower and uppercase letters, symbols" — pick the loosest one your dashboard offers, because NIST tells you not to impose composition rules. Length plus leaked-password checking does far more work than forcing a symbol.

The session values are the desk-worker defaults; per-role overrides come in step 6. ES256 is Supabase's recommended signing algorithm — the docs call elliptic curves "a faster alternative than RSA, while providing comparable security" with shorter signatures, and asymmetric signing is what lets PowerSync verify tokens through your JWKS endpoint without you sharing a secret.

Step 3: email deliverability, or nothing else works

Supabase's built-in email sender is capped at 2 emails per hour, project-wide, and the docs say you can only change it by configuring custom SMTP. Onboarding one customer with eight staff will exceed that in the first five minutes. Configure custom SMTP before you have users. Resend, Postmark and Amazon SES are all reasonable; Resend's free tier is 3,000 emails a month with a 100/day cap and one domain, and Pro is $20/month for 50,000 emails (checked July 2026).

DNS records at your DNS host (Cloudflare, Route 53, ...)
Your vendor's dashboard prints the exact names and values.
Paste theirs. The shapes below are the pattern to expect.

SPF - lists the servers allowed to send as this name
  Host:  send.brandco.com
  Type:  TXT
  Value: v=spf1 include:amazonses.com ~all
  Note:  ONE SPF record per name. Merge, never duplicate.

DKIM - a signature receivers check against a public key
  Host:  resend._domainkey.brandco.com
  Type:  TXT     (Resend and Postmark publish a TXT record;
                  Amazon SES gives you three CNAMEs instead)
  Value: p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...

MX - lets the vendor collect bounces on your behalf
  Host:  send.brandco.com
  Type:  MX      Priority: 10
  Value: feedback-smtp.us-east-1.amazonses.com

DMARC - what receivers do when SPF and DKIM fail
  Host:  _dmarc.brandco.com
  Type:  TXT
  Value: v=DMARC1; p=none; rua=mailto:dmarc@brandco.com

Check every one from a terminal before you send:
  dig +short TXT send.brandco.com
  dig +short TXT resend._domainkey.brandco.com
  dig +short MX  send.brandco.com
  dig +short TXT _dmarc.brandco.com

SPF publishes the list of servers allowed to send mail claiming to be from that name; ~all means "anything else is suspicious". Only one SPF record may exist per name, so if you already have Google Workspace on the root domain, add the include: to the existing record rather than creating a second one — two records is a hard fail.

DKIM adds a signature header that receivers verify against a public key in DNS, proving the message was not altered in transit; your vendor generates the key pair and shows you the record. DMARC ties the two together and tells receivers what to do on failure, and sends you reports at the rua address.

One alignment trap. DMARC only passes if the domain in the visible From: header lines up with the domain that SPF or DKIM authenticated. The default is relaxed alignment, where send.brandco.com counts as lining up with brandco.com. If you add adkim=s; aspf=s you switch to strict alignment, which demands an exact match, and then sending from a send. subdomain while your From: says @brandco.com fails every time. Leave alignment relaxed unless your From: address is on the exact same name as your DKIM signature.

Start at p=none with reports flowing to a mailbox you read, watch for a couple of weeks, then move to p=quarantine and eventually p=reject.

Skipping this now costs you delivery later. Google's sender guidelines, in force since 1 February 2024, require every sender to authenticate with SPF or DKIM, hold valid forward and reverse DNS records, use TLS, and keep spam complaints below 0.30% (Google's stated target is below 0.10%). Senders of "more than 5,000 messages per day to Gmail accounts" must publish SPF and DKIM and a DMARC record, align the From: domain, and offer one-click unsubscribe on marketing mail.

Microsoft applies a similar rule to Outlook.com consumer mailboxes: since 5 May 2025, domains sending more than 5,000 messages a day must pass SPF and DKIM and publish a DMARC record of at least p=none that aligns with one of them, or their mail is routed to the Junk folder. Microsoft has said outright rejection will follow on a date it has not yet named, so check its current guidance before launch.

A password reset that lands in spam is a support call and a lost afternoon, and there is no way to debug it from inside your app.

Send auth email from a subdomain

Use send.brandco.com or notify.brandco.com for transactional mail rather than the root domain. Reputation is tracked per sending domain, so if a marketing blast from a different tool damages the root domain's reputation, your password resets keep arriving. It also keeps SPF records simple, because the subdomain gets its own record instead of competing for the single root one.

Step 4: the schema

create extension if not exists citext;
create extension if not exists pgcrypto;

create table public.tenants (
  id                uuid primary key default gen_random_uuid(),
  name              text not null,
  domain            citext,
  domain_verify_token text,
  domain_verified_at  timestamptz,
  auto_join_role    text,
  seat_limit        int not null default 25,
  created_at        timestamptz not null default now()
);

create table public.roles (
  key   text primary key,
  rank  int  not null,      -- higher = more powerful
  label text not null
);

insert into public.roles (key, rank, label) values
  ('owner', 90, 'Owner'),
  ('admin', 80, 'Administrator'),
  ('sales_manager', 60, 'Sales Manager'),
  ('accounting', 55, 'Accounting'),
  ('customer_service', 40, 'Customer Service'),
  ('warehouse', 40, 'Warehouse'),
  ('sales_rep', 30, 'Sales Rep'),
  ('external_agent', 20, 'External Rep / Agent'),
  ('read_only', 10, 'Read Only');

create table public.memberships (
  tenant_id      uuid not null references public.tenants(id),
  user_id        uuid not null references auth.users(id),
  role           text not null references public.roles(key),
  status         text not null default 'active'
                 check (status in ('active','inactive')),
  can_view_cost  boolean not null default false,
  can_export     boolean not null default false,
  session_max_hours int,          -- null = tenant default
  deactivated_at timestamptz,
  deactivated_by uuid,
  created_at     timestamptz not null default now(),
  primary key (tenant_id, user_id)
);

create index memberships_by_user
  on public.memberships (user_id) where status = 'active';

create table public.trusted_devices (
  id           uuid primary key default gen_random_uuid(),
  user_id      uuid not null references auth.users(id),
  token_hash   bytea not null unique,
  label        text,               -- 'Maria iPad (Coterie)'
  platform     text,               -- 'ios' | 'web'
  mdm_enrolled boolean not null default false,
  first_seen_at timestamptz not null default now(),
  last_seen_at  timestamptz not null default now(),
  expires_at    timestamptz not null,
  revoked_at    timestamptz
);

tenants holds the customer company plus the domain-verification state and seat limit. roles is a real table with a rank column, which is what canGrant uses to stop an admin (80) minting an owner (90). memberships is the join between a user and a tenant, and it carries the role plus two named permission flags for the things that are genuinely per-person rather than per-role: cost visibility and export rights. session_max_hours allows a per-member override, which is how the iPad reps get 7 days while the controller gets 8 hours.

Everything is deactivated by status flag rather than deleted, because chapter 1's ledger references these rows forever. trusted_devices backs "remember this device" and records whether the device is MDM-enrolled, which is the signal that lets you grant the long session.

-- Supabase grants new public tables to the anon and
-- authenticated roles by default. Until RLS is on, anyone
-- with your publishable key can read the whole table.
-- Turn it on for every table, in the same migration.
alter table public.tenants         enable row level security;
alter table public.roles           enable row level security;
alter table public.memberships     enable row level security;
alter table public.trusted_devices enable row level security;

-- Read only your own tenant's row.
create policy tenants_read on public.tenants
  for select to authenticated
  using (id = public.jwt_tenant_id());

-- The role list is not secret.
create policy roles_read on public.roles
  for select to authenticated using (true);

-- See yourself; owners and admins see the whole roster.
create policy memberships_read on public.memberships
  for select to authenticated
  using (tenant_id = public.jwt_tenant_id()
         and (user_id = auth.uid()
              or public.jwt_role() in ('owner','admin')));

-- A user may list their own devices in order to revoke one.
create policy devices_read_own on public.trusted_devices
  for select to authenticated
  using (user_id = auth.uid());

create policy devices_revoke_own on public.trusted_devices
  for update to authenticated
  using (user_id = auth.uid())
  with check (user_id = auth.uid());

-- No insert/update/delete policy on memberships or tenants
-- on purpose: those writes go through server code holding
-- the secret key, which has already checked permissions.

This block is the one that people leave out of tutorials and regret. Enabling row level security is what makes a Postgres table private; the grants Supabase applies by default mean that until you run these lines, any browser holding your publishable key can select every membership row in every tenant.

Each policy states the smallest thing that is true: you can read your own tenant, your own memberships row (or the whole roster if you are an owner or admin), and your own devices. Writes to memberships have no policy at all, so they are refused for every client, and the only way to create one is server code that holds the secret key and has done its own check first. Run select relname, relrowsecurity from pg_class where relnamespace = 'public'::regnamespace after every migration and look for a false.

Step 5: put the claims into the JWT

create or replace function public.custom_access_token_hook(
  event jsonb
) returns jsonb
language plpgsql
stable
as $$
declare
  claims jsonb;
  m      record;
  p      record;
  uid    uuid := (event ->> 'user_id')::uuid;
begin
  claims := event -> 'claims';

  -- app_metadata is an OPTIONAL claim. If it is absent,
  -- jsonb_set on a nested path silently changes nothing
  -- and every policy would deny. Create the object first.
  if claims -> 'app_metadata' is null then
    claims := jsonb_set(claims, '{app_metadata}',
                        '{}'::jsonb);
  end if;

  -- Staff membership. A user may belong to more than one
  -- tenant (an outside agent repping three brands); we
  -- pick the highest-ranked one. If you need the user to
  -- choose, store an active_tenant_id and read it here.
  select ms.tenant_id, ms.role, ms.can_view_cost,
         ms.can_export
    into m
    from public.memberships ms
   where ms.user_id = uid
     and ms.status = 'active'
   order by (select r.rank from public.roles r
              where r.key = ms.role) desc
   limit 1;

  if m.tenant_id is not null then
    claims := jsonb_set(claims, '{app_metadata,tenant_id}',
                        to_jsonb(m.tenant_id::text));
    claims := jsonb_set(claims, '{app_metadata,role}',
                        to_jsonb(m.role));
    claims := jsonb_set(claims, '{app_metadata,can_view_cost}',
                        to_jsonb(m.can_view_cost));
    claims := jsonb_set(claims, '{app_metadata,can_export}',
                        to_jsonb(m.can_export));
  else
    -- Not staff? Maybe a retailer in the B2B portal.
    select pu.tenant_id, pu.customer_id
      into p
      from public.portal_users pu
     where pu.user_id = uid
       and pu.status = 'active'
     limit 1;

    if p.tenant_id is not null then
      claims := jsonb_set(claims, '{app_metadata,tenant_id}',
                          to_jsonb(p.tenant_id::text));
      claims := jsonb_set(claims, '{app_metadata,customer_id}',
                          to_jsonb(p.customer_id::text));
      claims := jsonb_set(claims, '{app_metadata,role}',
                          '"portal"'::jsonb);
    else
      claims := jsonb_set(claims, '{app_metadata,role}',
                          '"none"'::jsonb);
    end if;
  end if;

  return jsonb_set(event, '{claims}', claims);
end $$;

-- The auth service runs this function; nobody else may.
grant usage on schema public to supabase_auth_admin;
grant execute on function public.custom_access_token_hook
  to supabase_auth_admin;
revoke execute on function public.custom_access_token_hook
  from authenticated, anon, public;

grant select on public.memberships, public.roles,
                public.portal_users
  to supabase_auth_admin;

-- supabase_auth_admin is NOT a superuser and does not skip
-- RLS, so a grant alone is not enough: every table the hook
-- reads also needs a policy naming that role.
create policy auth_admin_read_memberships
  on public.memberships for select
  to supabase_auth_admin using (true);

create policy auth_admin_read_roles
  on public.roles for select
  to supabase_auth_admin using (true);

create policy auth_admin_read_portal_users
  on public.portal_users for select
  to supabase_auth_admin using (true);

Supabase calls this function every time it mints an access token, handing it a JSON object with three fields: user_id, the claims so far, and the authentication_method. We look up the user's active membership, pick the highest-ranked one if they belong to several tenants, and write tenant_id, role and the two permission flags into app_metadata.

If there is no staff membership we check the portal table, and only then fall back to role: none, which every RLS policy in this chapter treats as false. A deactivated employee's next token refresh therefore produces a token that can read nothing.

Three details are load-bearing. First, the guard that creates app_metadata if it is missing: Supabase's documentation lists it as an optional claim, and jsonb_set returns the input unchanged when the parent path does not exist, so without that guard the hook can silently do nothing and lock out your whole customer.

Second, placement: app_metadata is server-controlled and users cannot modify it, whereas user_metadata is user-editable, and putting a role there would let anyone promote themselves.

Third, the grants: the function must be executable by supabase_auth_admin and nobody else, and that role needs read access to the tables it queries. Read access here means two things, and missing the second is the classic way this hook returns nothing. supabase_auth_admin is an ordinary Postgres role, so it obeys row level security like everyone else. It needs the grant select and a policy on each table that names it, which is why the three auth_admin_read_* policies are there.

Enable the hook in the dashboard under Authentication → Hooks → Customize Access Token, selecting this function. After enabling, sign out and back in — existing tokens keep their old claims until they refresh.

Step 6: clients, the request proxy and per-role session policy

// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() { return cookieStore.getAll() },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options))
          } catch {
            // Called from a Server Component: the proxy
            // is refreshing the session, so ignore.
          }
        },
      },
    }
  )
}

// lib/supabase/admin.ts  - SERVER ONLY. Bypasses RLS.
import 'server-only'
import { createClient as createSb } from '@supabase/supabase-js'

export function createAdminClient() {
  if (typeof window !== 'undefined') {
    throw new Error('admin client used in the browser')
  }
  return createSb(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SECRET_KEY!,
    { auth: { persistSession: false,
              autoRefreshToken: false } }
  )
}

The server client reads and writes the auth cookies through Next's cookie store. The try/catch around setAll is required: Server Components, the React components Next.js renders on the server before any browser code runs, cannot set cookies, so the write throws there, and the proxy handles the refresh instead.

Recent versions of @supabase/ssr also hand setAll a second argument holding cache headers (Cache-Control, Expires, Pragma) that should be copied onto the HTTP response so a CDN never caches one user's session; the proxy below sets Cache-Control itself, which covers the same ground.

The admin client is separate. It uses the secret key, and disables session persistence because it has no user session to persist. Two guards protect it: the server-only import makes the build fail if any client component ever pulls this file into the browser bundle, and the runtime check throws if it somehow runs there anyway. The build-time guard is the one that saves you, because the runtime check fires after the key has already shipped.

// proxy.ts  (project root, Next.js 16+)
// On Next.js 15 and earlier this file is middleware.ts and
// the exported function is called middleware instead.
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

const PUBLIC = ['/login', '/invite', '/auth', '/_next',
                '/favicon.ico', '/api/health']

const MAX_HOURS: Record<string, number> = {
  owner: 8, admin: 8, accounting: 8,
  sales_manager: 12, sales_rep: 12,
  customer_service: 10, warehouse: 10,
  external_agent: 8, read_only: 10,
  portal: 24,          // retailer B2B portal user
}

const MFA_REQUIRED = new Set([
  'owner', 'admin', 'accounting',
  'sales_manager', 'external_agent',
])

// Cookies written during a refresh live on `res`. Building
// a bare NextResponse.redirect() throws them away and the
// user loses the session they just renewed. Copy them over.
function redirectTo(res: NextResponse, url: URL) {
  const out = NextResponse.redirect(url)
  res.cookies.getAll().forEach(c => out.cookies.set(c))
  return out
}

export async function proxy(request: NextRequest) {
  let response = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() { return request.cookies.getAll() },
        setAll(cookiesToSet, headers) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value))
          response = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options))
          // Cache headers the library hands back. They stop
          // a CDN storing one user's session page.
          Object.entries(headers ?? {}).forEach(([k, v]) =>
            response.headers.set(k, String(v)))
        },
      },
    }
  )

  // Run NOTHING between createServerClient and this call.
  // It verifies the JWT signature and refreshes if needed.
  const { data } = await supabase.auth.getClaims()
  const claims = data?.claims as Record<string, any> | undefined

  // Never let a CDN cache a page that carries a session.
  response.headers.set('Cache-Control',
    'private, no-store, max-age=0')

  const path = request.nextUrl.pathname
  const isPublic = PUBLIC.some(p => path.startsWith(p))

  if (!claims) {
    if (isPublic) return response
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    url.searchParams.set('next', path)
    return redirectTo(response, url)
  }

  const role = claims.app_metadata?.role ?? 'none'
  const isIpad = request.cookies.get('device_kind')?.value
                 === 'managed_ipad'

  // Original sign-in time: the EARLIEST amr entry, which
  // survives refreshes. Order is not guaranteed, so take
  // the minimum rather than amr[0].
  const stamps: number[] = (claims.amr ?? [])
    .map((e: any) => Number(e?.timestamp))
    .filter((n: number) => Number.isFinite(n))
  const authTime = stamps.length ? Math.min(...stamps)
                                 : Number(claims.iat)
  const ageHours = (Date.now() / 1000 - authTime) / 3600
  const cap = isIpad ? 24 * 7 : (MAX_HOURS[role] ?? 8)

  if (ageHours > cap) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    url.searchParams.set('reason', 'session_expired')
    const out = redirectTo(response, url)
    // Drop the auth cookies in THIS browser. The refresh
    // token stays valid server-side, so a real revocation
    // still has to go through the offboard path.
    request.cookies.getAll()
      .filter(c => c.name.startsWith('sb-'))
      .forEach(c => out.cookies.delete(c.name))
    return out
  }

  // Step-up: privileged roles must reach aal2.
  if (MFA_REQUIRED.has(role) && claims.aal !== 'aal2'
      && !path.startsWith('/account/mfa')) {
    const url = request.nextUrl.clone()
    url.pathname = '/account/mfa'
    return redirectTo(response, url)
  }

  return response
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|.*\\.png$).*)'],
}

A naming note first, because this file changed name recently. Next.js 16 deprecated the middleware.ts convention and renamed it to proxy.ts, renaming the exported function from middleware to proxy at the same time (checked July 2026). On Next.js 15 and earlier, use the old names. The codemod npx @next/codemod@canary middleware-to-proxy . does the rename for you, and Supabase's current guides call this file the Proxy. The job it does has not changed at all.

This runs on every matched request. It builds a Supabase client bound to the request and response cookies, then calls getClaims(), which verifies the JWT signature against your published public keys and transparently refreshes an expired access token, writing the new cookies onto the response.

Supabase's own sample warns in a comment: "Do not run code between createServerClient and supabase.auth.getClaims(). A simple mistake could make it very hard to debug issues with users being randomly logged out." The same sample insists that if you build a fresh response you must copy the cookies across, or "you may be causing the browser and server to go out of sync and terminate the user's session prematurely" — that is what the redirectTo helper exists for.

The setAll callback also receives the cache headers the library wants on the response, and the loop above applies them. The Cache-Control header stops a CDN from storing a signed-in page and serving it to the next visitor, which is the worst caching bug you can ship in an ERP.

Then two policies that Supabase's project-wide settings cannot express. The per-role absolute cap compares the original sign-in time against a table of hours per role, with managed iPads getting seven days and retailer portal users getting 24 hours, matching the policy table above. That time comes from the earliest amr entry, which is tied to the session and survives refreshes; taking the minimum rather than the first element avoids depending on an ordering nobody promises.

Over the cap, clear the browser's auth cookies and redirect with a reason so the login page can explain rather than looking broken. Be honest about what that does: it ends the session in that browser, not on the server. Real revocation still goes through the offboard path.

The MFA gate sends any privileged role whose token is not aal2 to the enrollment or challenge page, and exempts that page itself so there is no redirect loop. The matcher excludes static assets so you are not running auth logic on image requests.

Step 7: TOTP enrollment and challenge

'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'

export function EnrollTotp({ onDone }: { onDone: () => void }) {
  const supabase = createClient()
  const [qr, setQr] = useState<string>()
  const [secret, setSecret] = useState<string>()
  const [factorId, setFactorId] = useState<string>()
  const [code, setCode] = useState('')
  const [err, setErr] = useState<string>()

  async function start() {
    const { data, error } = await supabase.auth.mfa.enroll({
      factorType: 'totp',
      friendlyName: 'Authenticator app',
    })
    if (error) return setErr(error.message)
    setFactorId(data.id)
    setQr(data.totp.qr_code)      // an SVG data URL
    setSecret(data.totp.secret)   // for manual entry
  }

  async function verify() {
    if (!factorId) return
    const { data: ch, error: cErr } =
      await supabase.auth.mfa.challenge({ factorId })
    if (cErr) return setErr(cErr.message)

    const { error: vErr } = await supabase.auth.mfa.verify({
      factorId, challengeId: ch.id, code,
    })
    if (vErr) {
      // An unverified factor left behind will block a
      // retry with "factor already exists". Clean it up.
      await supabase.auth.mfa.unenroll({ factorId })
      setFactorId(undefined); setQr(undefined)
      return setErr('That code was not accepted. Start again.')
    }

    await fetch('/api/account/recovery-codes',
                { method: 'POST' })
    onDone()
  }

  return (
    <div>
      {!qr && <button onClick={start}>Set up MFA</button>}
      {qr && (
        <>
          <img src={qr} alt="Scan with your authenticator" />
          <p>Cannot scan? Enter this key:
             <code>{secret}</code></p>
          <input value={code} inputMode="numeric"
                 maxLength={6}
                 onChange={e => setCode(e.target.value)} />
          <button onClick={verify}>Confirm</button>
        </>
      )}
      {err && <p role="alert">{err}</p>}
    </div>
  )
}

Three calls in sequence. enroll creates an unverified TOTP factor and returns an SVG QR code you can drop straight into an img tag, plus the raw secret for manual entry — always show both, because some users have a broken camera or use a desktop password manager. challenge creates a challenge for that factor. verify submits the six digits; on success the factor becomes active and the session moves to aal2.

The unenroll call in the failure branch matters more than it looks: an unverified factor sticks around, and enrolling again under the same friendly name fails with a confusing "a factor with this friendly name already exists", so clean up before letting the user retry.

Immediately after success, generate and display recovery codes — that endpoint should create ten random codes, store only their hashes, and return the plaintext exactly once. Supabase rate-limits MFA challenge and verify at 15 requests per hour per IP, which is generous for a real person and hostile to a code-guessing script.

For the login flow, after signInWithPassword succeeds, call supabase.auth.mfa.getAuthenticatorAssuranceLevel(). If it returns currentLevel: 'aal1' and nextLevel: 'aal2', the user has a verified factor and must complete a challenge before proceeding. If both are aal1, they have no factor, and the proxy from step 6 sends privileged roles to enrollment.

Step 8: revoke access automatically on role change

create or replace function public.on_membership_changed()
returns trigger language plpgsql
security definer set search_path = public as $$
begin
  if (tg_op = 'UPDATE'
      and (new.role is distinct from old.role
        or new.status is distinct from old.status
        or new.can_view_cost is distinct from old.can_view_cost
        or new.can_export is distinct from old.can_export))
  then
    insert into public.auth_events(
      tenant_id, actor_id, subject_id, event, detail)
    values (new.tenant_id,
            auth.uid(),   -- null when a worker made the change
            new.user_id,
            'member.role_changed',
            jsonb_build_object('old_role', old.role,
                               'new_role', new.role,
                               'old_status', old.status,
                               'new_status', new.status));

    -- Deactivation is the only case that needs a hard ban.
    -- A demotion is handled by the token hook on refresh.
    if new.status = 'inactive'
       and old.status is distinct from 'inactive' then
      insert into public.job_queue(kind, payload)
      values ('ban_user',
              jsonb_build_object('user_id', new.user_id));
    end if;
  end if;
  return new;
end $$;

create trigger memberships_changed
  after update on public.memberships
  for each row execute function public.on_membership_changed();

Any change to role, status or the permission flags writes an audit row capturing old and new values, which is exactly what an access review asks for. Deactivation also enqueues a ban job; the worker (pg-boss, per chapter 9) picks it up and calls the Admin API endpoint shown earlier, because Postgres cannot make HTTP calls itself.

A demotion does not need the ban — the access token hook reads the membership row fresh on every token, so the user's next refresh carries the lower role. Either way the gap closes within one access-token lifetime, and usually within seconds. The trigger is security definer so it can write to auth_events and job_queue even though the client roles have no insert rights on them.

Step 9: verify it works

# Set these first. Use the PUBLISHABLE key here, never the
# secret one - these calls are simulating a real browser.
SUPABASE_URL=https://abcdefgh.supabase.co
PUBLISHABLE_KEY=sb_publishable_...

# 1. Sign in. Read the password from a prompt so it never
#    reaches shell history or the process list.
read -r -s -p "password: " PW; echo
BODY=$(jq -n --arg e maria@brandco.com --arg p "$PW" \
       '{email:$e,password:$p}')
unset PW
TOKEN=$(curl -s -X POST \
  "$SUPABASE_URL/auth/v1/token?grant_type=password" \
  -H "apikey: $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d "$BODY" | jq -r .access_token)

# Decode the payload. base64url needs padding restored,
# so do it in python rather than fighting `base64`.
python3 - "$TOKEN" <<'PY'
import base64, json, sys
p = sys.argv[1].split('.')[1]
p += '=' * (-len(p) % 4)
print(json.dumps(json.loads(
      base64.urlsafe_b64decode(p)), indent=2))
PY
# Expect app_metadata.tenant_id, .role, .can_view_cost

# 2. Confirm RLS scopes rows to this rep.
curl -s "$SUPABASE_URL/rest/v1/orders?select=id,rep_user_id" \
  -H "apikey: $PUBLISHABLE_KEY" \
  -H "Authorization: Bearer $TOKEN" | jq 'length'
# Expect only orders where rep_user_id = Maria's uuid

# 3. Confirm the MFA gate on payments.
curl -s "$SUPABASE_URL/rest/v1/payments?select=id" \
  -H "apikey: $PUBLISHABLE_KEY" \
  -H "Authorization: Bearer $TOKEN" | jq 'length'
# Expect 0 while the token is aal1; rows only once aal2.

# 4. Confirm the public key set is reachable.
curl -s "$SUPABASE_URL/auth/v1/.well-known/jwks.json" | jq .
# Expect kty/kid/alg entries; PowerSync fetches this.

Run these four checks after every change to policies or claims, and put them in CI with a seeded test tenant. Two tools do the work here: curl sends the HTTP request from your terminal, and jq formats the JSON that comes back and pulls single fields out of it.

Test 1 proves the access token hook fired and the claims landed in the right place; reading the password from a prompt keeps it out of ~/.zsh_history and out of anything watching the process table. Test 2 proves row scoping works from outside your app — this is the test that catches the case where the UI filters correctly but the API does not. Test 3 proves the MFA restrictive policy is applied; remember that RLS filters rows rather than returning 403, so a count of zero is the success signal and a 200 status tells you nothing.

Test 4 proves the JWKS endpoint is serving public keys, which is what PowerSync and any other JWT consumer needs. Add a fifth test that signs in as one rep and requests another rep's order by id, asserting an empty result.

Test authorization the way an attacker would

Every permission test should be written from a second user's token trying to reach the first user's data, not from the owner's token confirming they can see everything. A policy that passes "owner sees all" and fails "rep sees only theirs" looks perfectly fine until the day a rep files a support ticket containing a screenshot of somebody else's orders.

Login is the first step, not the whole answer Login is the first step, not the whole answer. The top row is authentication: proving the person is who they say. Everything useful happens underneath. The system still has to decide which customer company this request belongs to, what the person's role permits, and which individual rows they may see. Answering only the first question and treating the session as permission is how a sales rep ends up reading another rep's accounts. WHAT HAPPENS WHEN A CUSTOMER LOGS IN User enters email Auth service verifies identity Session issued signed cookie Every later request carries the cookie Authentication answers WHO. Three more questions remain. Which tenant? resolved from the hostname or the membership record, then SET LOCAL on the database connection Which role? rep, warehouse, controller, owner — decides which screens and actions are available at all Which rows? a rep sees only their own accounts. Enforced by the database via row-level security, not by your code Logging in is not permission A valid session proves identity and nothing else. Every one of the three questions must still be answered.
Login is the first step, not the whole answer. The top row is authentication: proving the person is who they say. Everything useful happens underneath. The system still has to decide which customer company this request belongs to, what the person's role permits, and which individual rows they may see. Answering only the first question and treating the session as permission is how a sales rep ends up reading another rep's accounts.

Field notes & further reading

  • Supabase — Sessions: the authoritative description of access-token expiry, refresh-token reuse detection and the 10-second reuse interval, plus the time-box, inactivity timeout and single-session settings, which plans have them, and the note that expired sessions are only cleaned up about 24 hours later.
  • Supabase — Custom Access Token Hook: the exact function signature, the three fields of the event object, the list of required versus optional claims (this is where app_metadata is documented as optional), and the grants the auth service needs.
  • Supabase — JWT Signing Keys: standby keys, ES256 versus RS256 versus HS256, the JWKS discovery URL, the 10-minute edge and 10-minute client caches, and the "expiry plus 15 minutes" wait before revoking an old key.
  • Supabase — Rate Limits: the full default table reproduced above, the token-bucket behavior, and how to enable Sb-Forwarded-For so a server-rendered app is not rate-limited as one IP.
  • OWASP Session Management Cheat Sheet: cookie attributes, the __Host- prefix, session identifier length and entropy, the idle and absolute timeout ranges quoted above, and the salted-hash rule for logging session ids.
  • NIST SP 800-63B rev 4: the current word on password rules — no composition rules, no forced rotation, 15 characters for single-factor and a floor of 8 inside multi-factor — plus the AAL definitions and why PSTN/SMS is a restricted authenticator.
  • Google — Email sender guidelines: the requirements in force since 1 February 2024, including the 5,000-messages-per-day bulk threshold, SPF plus DKIM plus DMARC, From-domain alignment, and the 0.30% spam-rate ceiling that governs whether your password resets arrive.
  • Next.js — the proxy.js file convention: the rename of middleware.ts to proxy.ts in Next.js 16, the codemod that does it for you, matcher syntax including negative matching, and the note that the proxy now defaults to the Node.js runtime.
  • PowerSync — Custom authentication: the JWT contract for offline clients — required claims, the 86,400-second maximum lifetime, the 60-minute recommendation and rejection rule, the supported signature algorithms, and the JWKS key requirements.
Exercise

1. Build the invitation loop end to end. Create the invitations table from this chapter in a fresh Supabase project. Write the create endpoint and the accept endpoint. Then deliberately break it five ways and confirm each is rejected with the same generic message: accept an invitation twice, accept one you have manually expired by setting expires_at to yesterday, accept one you have revoked, accept with a token you invented, and accept a valid token twice from two terminals at the same moment. Confirm your auth_events table has a row for the successful path and that the raw token appears nowhere in your database or your server logs.

2. Prove the rep row-scope with two real tokens. Create two users with the sales_rep role in the same tenant, and three orders — one assigned to rep A, two to rep B. Sign in as each with curl, and confirm from the REST API (not the UI) that A sees exactly one order and B sees exactly two. Then have A attempt an update that sets rep_user_id to B on their own order, and confirm the with check clause refuses it. Change rep A's role to sales_manager in the database, confirm the trigger wrote an audit row, sign in again, and confirm A now sees all three. Finally set A's membership status to inactive, refresh the token, and confirm the claims contain role: none and every query returns zero rows.

3. Prove RLS is actually on. Run select relname, relrowsecurity from pg_class where relnamespace = 'public'::regnamespace order by 1 and confirm no table shows false. Then, with only the publishable key and no Authorization header, try to read memberships, api_keys and invitations over the REST API and confirm all three come back empty.

When all three exercises are done you should have: a working invite-and-accept flow whose tokens are unguessable, single-use and hashed at rest; a JWT that carries tenant, role and permission flags from your own tables; RLS policies that scope rows by rep, gate finance tables behind MFA, and refuse a row the user is not allowed to leave behind; and automatic audit plus account lockout whenever a role or status changes. That is a defensible access-control foundation for a real customer.