Here's an uncomfortable default: a fresh self-hosted Supabase deployment accepts 123456 as a password. Out of the box, GoTrue (Supabase Auth) enforces a six-character minimum with no character requirements and no leaked-password checking. On Supabase Cloud there's a dashboard toggle for all of this — on self-hosted, the settings exist but they're buried in environment variables that the default docker-compose.yml never mentions. If you've already worked through hardening your self-hosted Supabase for production, password policy is the piece most checklists skip.
This guide covers everything GoTrue gives you for password security on a self-hosted instance: minimum length and character-class rules, breach detection via Have I Been Pwned, how passwords are actually stored, and how to require reauthentication before password changes — plus the rollout gotchas that only bite existing users.
What the Defaults Actually Are
Three settings control password acceptance in GoTrue, and here's what an untouched deployment ships with:
| Setting | Default | What it means |
|---|---|---|
GOTRUE_PASSWORD_MIN_LENGTH | 6 | Six characters is enough |
GOTRUE_PASSWORD_REQUIRED_CHARACTERS | (empty) | No complexity rules at all |
GOTRUE_PASSWORD_HIBP_ENABLED | false | Known-breached passwords are accepted |
None of these appear in the standard self-hosted .env template, which is why the question comes up so often — the most-upvoted GitHub discussion on the topic is people discovering that the dashboard setting from Cloud simply doesn't exist in self-hosted Studio. The configuration lives entirely in the auth container's environment. If you're not yet familiar with how these variables flow through the stack, the self-hosted environment variables guide covers the plumbing.
Setting Minimum Length and Character Requirements
Both settings go in your .env and get passed to the auth service in docker-compose.yml:
auth:
environment:
GOTRUE_PASSWORD_MIN_LENGTH: 12
GOTRUE_PASSWORD_REQUIRED_CHARACTERS: "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789"
GOTRUE_PASSWORD_REQUIRED_CHARACTERS takes colon-separated character classes. A password must contain at least one character from each class. The example above requires a lowercase letter, an uppercase letter, and a digit. To also require a symbol, append a fourth class:
GOTRUE_PASSWORD_REQUIRED_CHARACTERS='abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};"|<>?,./~'
Two gotchas:
- Escaping colons. If a literal
:should be part of a character class, escape it with a backslash — the colon is the class separator. - Quote carefully in
.env. Symbol classes are full of characters that shells and Docker Compose interpolation love to mangle ($, backticks, quotes). Single-quote the value and verify what actually reached the container withdocker compose exec auth env | grep PASSWORD.
On the client side, a rejected password surfaces in supabase-js as an error with code weak_password, and the error includes which rules failed. Handle it explicitly in your signup form rather than showing a generic failure — users can't fix what you don't tell them.
A word of restraint from someone who has seen the research: NIST's current guidance favors length over composition rules. A 12-character minimum with HIBP checking (next section) is a stronger policy than 8 characters with four mandatory character classes. Complexity rules mostly produce Password1!.
Leaked Password Protection with Have I Been Pwned
This is the setting with the best security-per-effort ratio, and almost nobody enables it on self-hosted:
auth:
environment:
GOTRUE_PASSWORD_HIBP_ENABLED: "true"
When enabled, GoTrue checks every new password against the Have I Been Pwned Pwned Passwords database — over 900 million passwords exposed in real breaches. The check uses k-anonymity: GoTrue sends only the first five characters of the password's SHA-1 hash to the HIBP API and matches the suffix locally. The plaintext password, and even its full hash, never leaves your server.
Because this introduces an external API call into your signup path, GoTrue gives you control over failure behavior:
- Fail open (default): if the HIBP API is unreachable, the password is accepted. Signups keep working during an HIBP outage.
- Fail closed (
GOTRUE_PASSWORD_HIBP_FAIL_CLOSED: "true"): if the check can't complete, the request is rejected. Stricter, but an HIBP outage now blocks your signups.
For most applications, fail-open is the right trade-off — a brief window of unchecked passwords beats a hard dependency on a third-party API. If your threat model demands fail-closed, make sure your monitoring stack alerts on auth error rates so an HIBP outage doesn't silently become a signup outage.
Note that HIBP checking applies at password set time — signup, password change, and recovery. It does not retroactively scan existing users, and by design it can't: GoTrue only ever has the plaintext at the moment the user submits it.
How Passwords Are Stored
For the threat model where your database itself leaks: GoTrue hashes passwords with bcrypt before they touch Postgres, stored in auth.users.encrypted_password. It also verifies Argon2 hashes, which matters if you migrated users from another auth system that used Argon2 — imported users can log in with their existing hashes, and GoTrue handles them transparently.
Two operational implications:
- Never SELECT-grant
auth.usersto exposed roles. The hashes are slow to crack, but they're not un-crackable for weak passwords — which is exactly why the policy settings above matter. Your database roles and permissions should treat theauthschema as radioactive. - Backups contain password hashes. Your backup files are as sensitive as your live database. Encrypt them at rest and control access to your backup storage.
Requiring Reauthentication for Password Changes
By default, any request with a valid access token can change the account password. If a token is stolen — XSS, a leaked log, a shared machine — the attacker can lock the real user out. GoTrue can require fresh proof of identity first:
auth:
environment:
GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION: "true"
With this enabled, calling updateUser({ password }) requires a recent reauthentication: the client calls supabase.auth.reauthenticate(), the user receives a nonce by email or SMS, and the password change must include it. It's one extra step for users and a meaningful barrier against session-theft account takeover. It pairs naturally with the token hygiene covered in the JWT and session security guide.
While you're in this part of the config, two adjacent protections worth confirming: rate limits on the auth endpoints (GoTrue's GOTRUE_RATE_LIMIT_* family throttles password attempts and email sends) and CAPTCHA on signup and login, which stops credential-stuffing bots from burning through a leaked password list against your login page.
Rolling Out a Policy to an Existing User Base
Policy changes apply only when a password is set — signup, change, or reset. Tightening GOTRUE_PASSWORD_MIN_LENGTH from 6 to 12 does not lock out existing users with 8-character passwords; they'll keep logging in fine and only hit the new policy when they next change their password. That's the sane behavior, but plan for its consequences:
- Password reset flows enforce the new policy. A user resetting a forgotten password must now meet the new rules — make sure your reset UI communicates the requirements before they type, not via a rejection after.
- Your weakest passwords stay weak indefinitely. If the old policy mattered enough to change, consider a gradual forced-rotation via your app logic for high-privilege accounts. GoTrue won't do this for you.
- Restart, don't reload. GoTrue reads its environment at startup. Changing
.envdoes nothing until the auth container is recreated:docker compose up -d auth. It's a seconds-long blip, but sequence it sensibly if you're also running zero-downtime deployments.
And a step that's skipped surprisingly often: test the policy from the outside. Attempt a signup with a deliberately weak password against your production URL and confirm you get weak_password back. Config that was never verified is config that isn't there.
Managing Auth Config Across Projects with Supascale
The mechanics above are simple for one project. The failure mode shows up at three or four projects: each has its own .env, each was hardened at a different time, and nobody remembers which instance still accepts six-character passwords. Auth configuration drift is invisible until the incident review.
Supascale treats auth configuration as a first-class, per-project setting rather than a loose environment file. You configure password policy, OAuth providers, and SMTP through the UI, and Supascale renders the environment and restarts the affected service — no SSH session, no hand-editing compose files, no forgotten restart. Because every project's configuration is visible in one place, "which of my instances has HIBP enabled?" is a glance instead of an audit. Licenses are one-time purchases from $99 with no per-project fees, so hardening your fifth project costs the same as your first: nothing extra.
To be clear about scope: Supascale manages the configuration and deployment layer. The enforcement is all GoTrue — everything in this guide works identically whether you manage the env vars by hand or through a UI. The value is consistency across projects, not different capabilities.
Conclusion
Self-hosted Supabase ships with a password policy from 2012: six characters, anything goes. Fixing it takes four environment variables and a container restart — set GOTRUE_PASSWORD_MIN_LENGTH to 12, enable GOTRUE_PASSWORD_HIBP_ENABLED, decide your HIBP failure mode deliberately, and turn on reauthentication for password changes. Favor length and breach-checking over complexity theater, tell your frontend how to render weak_password errors, and remember that the policy only touches passwords set after the change. Twenty minutes of configuration closes off the single most common account-takeover path there is: users reusing passwords that are already in a breach dump.
