This morning I had a UniFi Network 8.x controller humming along on an Ubuntu box that was getting old. Keeping the software current is a must for a quiet life — so I decided to do the clean thing: fresh Ubuntu Server LTS, latest UniFi, from scratch.
Glenn R.’s installer makes that a one-liner:
curl -sO https://get.glennr.nl/unifi/install/install_latest/unifi-latest.sh \
&& bash unifi-latest.sh
It brought up the latest UniFi OS Server. At the setup screen I made a deliberate choice: a local account, not linked to any UI.com / Ubiquiti cloud account — I wanted this box self-contained, no cloud dependency. I created the admin user, restored the backup I’d pulled from the 8.x controller, and… surprise: I couldn’t log in. The password I’d just set was rejected outright.
That one setup choice — local, no cloud — is exactly what would later close every easy door: no UI.com account meant no cloud password reset, and I hadn’t wired up SMTP either.
The rabbit hole
A little searching shows the symptom is common: set up a fresh UniFi OS, restore a backup, and end up unable to log into the console with the password you thought you set. (Whether the restore is actually to blame is a question I come back to at the end — with a surprising answer.) So I worked through the advice the forums hand you — and watched all of it miss.
- The MongoDB reset. Every thread points here. It “works” in the sense that it happily rewrites an admin record in Mongo — but the login still fails. All it touches is the Network application admin, not the UniFi OS console you actually sign into. Best case you recover a username; you never get back in.
- Configuring an SMTP server to unlock the “Forgot Password” email. Nothing — the link never even appears for a local account that had no mail server set up beforehand. The official docs confirm why:Local credentials are unique to a specific UniFi instance and cannot be recovered unless an SMTP server was manually configured beforehand.
Then I found Andy Sturniolo’s write-up, which finally points at the right place. I automated it a little — and turned the fix into the recipe below.
Why the usual fixes miss
UniFi OS and the classic Network Application are two different products with two different logins. A decade of “reset UniFi password” guides were all written for the old one:
// UniFi Network Application (NOT UniFi OS Server)
mongo --port 27117 ace
db.admin.update({ "name": "admin" },
{ $set: { "x_shadow": "<bcrypt-hash>" } })
That’s real, and it works — for the classic Network Application, where admins live in MongoDB (ace.admin) with a bcrypt x_shadow. It does nothing for a UniFi OS Server console login, because UniFi OS doesn’t authenticate against that database at all. The layers are separate, and conflating them is the whole trap:
| Layer | What it is | Where its users live |
|---|---|---|
UniFi OS console (:11443) | The OS login — the thing you’re locked out of | postgres · ulp-go."user" |
| Network application | Sites, devices, WLANs | mongodb · ace.admin (the bcrypt story) |
So the admin you can reach in MongoDB is the Network application admin — a different login entirely. Rewrite it all you like; the console at :11443 never notices.
Where the password actually lives
ith root on the host and podman exec into the uosserver container, you can go looking. Ruling things out one by one:
unifi-corePostgres DB — hasuser_settings,user_certificates… but no password column anywhere.config/cache/users.json— has the account (username: admin, empty email) but no password field, only apassword_revisioncounter. And the path sayscache/: it’s regenerated on every boot, so editing it is pointless.unifi-directory— LDAP / SCIM / AD federation tables, not the local account.uidb.json— just the device catalog.
The databases inside the container:
- postgres
- ucs-agent
- uid
- ulp-go
- ulp-go-syslog
- unifi-core
- unifi-directory
- unifi-identity-update
ulp-go — the “Ubiquiti Login Provider”, a Go service — turned out to be the one. It owns a table literally named "user":
id integer
unique_id varchar
login bytea -- encrypted, not plaintext
password varchar -- ← argon2id hash
password_revision bigint
only_local_account boolean
...
SELECT id, (password IS NOT NULL) AS has_pw FROM "user";
id | has_pw
----+--------
1 | t
There it was: a single local user, id 1, with an argon2id hash sitting in password.
The hashing details that matter
The stored value is standard argon2id in PHC string format:
$argon2id$v=19$m=65536,t=1,p=5$<base64-salt>$<base64-hash>
Parameters: memory 65536 KiB, time cost 1, parallelism 5, output length 32, version 0x13. ulp-go parses these straight from the PHC string on verify, so any correctly-formatted argon2id hash with a random salt validates — there’s no extra pepper in the mix.
Verified end-to-end. To be sure it wasn’t luck, I re-ran the whole thing on a clean UniFi OS Server 5.1.132 box: write a fresh argon2id hash into
ulp-go."user", thenPOSTto/api/auth/login— HTTP 200, full account object returned. No pepper, no surprises. That test also caught a bug (see the preflight note below).
You don’t even need to install anything: unifi-core bundles a pure-JS argon2 (@noble/hashes), and the container ships Node at /usr/bin/node24. The tools to unlock the door are already inside the room.
One gotcha cost a run: put the generator script in /tmp and require('@noble/hashes/argon2') fails, because Node resolves modules relative to the script’s directory, not the working directory. The fix is one environment variable — NODE_PATH=/usr/share/unifi-core/app/node_modules.
The recipe
Everything runs on the UniFi OS host, as root. One helper keeps the lines short — it runs podman as the user that owns the container (default uosserver):
PODMAN="sudo -u uosserver -H env XDG_RUNTIME_DIR=/run/user/$(id -u uosserver) podman"
Check two things before you cook
The recipe assumes the container is called uosserver and that your account is id = 1. On a stock install both hold — but confirm them, and if either differs, use your real values (the container name after every exec, and the id in step 5).
# confirm the container name — whatever it prints is what goes after every "exec"
$PODMAN ps --format '{{.Names}} {{.Status}}'
# list the accounts by id — identify yours by has_local_pw = t
$PODMAN exec uosserver su postgres -c \
'psql -d ulp-go -c "SELECT id, (substring(password from 2 for 8)='"'"'argon2id'"'"') AS has_local_pw,
status, to_timestamp(password_revision) AS pw_set FROM \"user\" ORDER BY id;"'
Pick the row by
has_local_pw = t, not byonly_local_account. That flag is a red herring: a normally created local owner showsonly_local_account = fand is still perfectly resettable. The reliable signal is that the row carries an argon2id password. The login itself is encrypted, so you can’t read the username here — single-owner installs are almost alwaysid = 1.
Don’t fancy typing all that? The whole recipe is packaged as one script — preflight, confirmation prompt, and rollback baked in. Fetch it, skim it, and run it on the host:
curl -fsSL https://gitlab.com/miguelcalidade/unifi-os-password-reset/-/raw/main/reset-unifi-os-password.sh -o reset-unifi-os-password.sh # skim it, then: sudo bash reset-unifi-os-password.sh
Step 1 — Make a rollback point
What it does: copies the current password hash to a side table. If anything goes sideways, this is your undo button.
$PODMAN exec uosserver su postgres -c \
'psql -d ulp-go -c "CREATE TABLE user_pwbak AS SELECT id,password,password_revision FROM \"user\";"'
Step 2 — Drop in a tiny hash-maker
What it does: writes a 6-line generator into the container. It reuses the argon2 library UniFi already ships, so nothing gets installed.
$PODMAN exec -i uosserver bash -c 'cat > /tmp/genhash.js' <<'JS'
const fs=require('fs'), crypto=require('crypto');
const pw=fs.readFileSync(0), salt=crypto.randomBytes(16);
const b64=b=>Buffer.from(b).toString('base64').replace(/=+$/,'');
const {argon2id}=require('@noble/hashes/argon2');
const h=argon2id(pw,salt,{t:1,m:65536,p:5,dkLen:32});
process.stdout.write(`$argon2id$v=19$m=65536,t=1,p=5$${b64(salt)}$${b64(Buffer.from(h))}`);
JS
Step 3 — Type the new password
What it does: reads your chosen password into a variable — nothing is echoed to the screen, and it never lands in argv or shell history.
read -rsp "New console password: " NEWPW; echo
Step 4 — Cook the hash
What it does: feeds the password to the generator on stdin and gets back a PHC argon2id string. NODE_PATH points Node at the bundled module.
HASH=$(printf '%s' "$NEWPW" | $PODMAN exec -i uosserver bash -c \
'cd /usr/share/unifi-core/app && NODE_PATH=$PWD/node_modules /usr/bin/node24 /tmp/genhash.js')
Step 5 — Serve it into the table
What it does: writes the new hash onto the local owner (id = 1) and bumps password_revision to invalidate any stale sessions.
printf 'UPDATE "user" SET password=%s, password_revision=EXTRACT(epoch FROM now())::bigint WHERE id=1;' "'$HASH'" \
| $PODMAN exec -i uosserver su postgres -c 'psql -d ulp-go -v ON_ERROR_STOP=1'
Step 6 — Clean the kitchen, then log in
What it does: removes the generator and clears the variables. Then sign in at https://<host>:11443 and change the password once via Settings → Admins, so it also goes through UniFi’s official write path.
$PODMAN exec uosserver rm -f /tmp/genhash.js; unset NEWPW HASH
Rollback
If the new password ever doesn’t take (e.g. a future firmware changes the scheme), restore the old hash from the backup table:
$PODMAN exec -i uosserver su postgres -c 'psql -d ulp-go' <<'SQL'
UPDATE "user" u SET password=b.password, password_revision=b.password_revision
FROM user_pwbak b WHERE u.id=b.id;
DROP TABLE user_pwbak;
SQL
Lessons
- UniFi OS Server ≠ UniFi Network Application. The MongoDB trick is for the old product. On UniFi OS the console login is Postgres
ulp-go."user", argon2id. - Local accounts have no recovery by default. Configure a UI.com owner or SMTP during setup, or you’re one forgotten password away from this exact rabbit hole.
- The restore doesn’t touch the console password — I checked. Neither does an IP change (see the postscript). If you’re locked out, the cause is almost always a wrong or mistyped local password that a later reboot finally forced you to face. The reset above is the way back, whatever the cause.
only_local_accountis a red herring. A normal local owner readsf; identify the resettable row by its argon2id password instead.- Keep plaintext out of
argv. Pipe passwords over stdin, and back up the hash before you overwrite it.
Postscript: so what actually locked me out?
Writing this up nagged at me — I’d blamed the restore, but never proven it. So I stood up a clean UniFi OS Server 5.1.132 box and reproduced my own steps, watching the credential the whole time. I fingerprinted ulp-go."user" with an md5() of the password column — so I could compare it without reading it — then ran the same backup restore, then changed the machine’s IP.
| Stage | unique_id | password fingerprint (md5) | login |
|---|---|---|---|
| Fresh install | a2d45f96… | 45eb2ca4… | works |
| After restore | a2d45f96… | 45eb2ca4… | works |
| After IP change | a2d45f96… | 45eb2ca4… | works |
Byte-identical at every stage. Neither the restore nor the IP change touches the console credential — and the account kept authenticating normally on the new address.
The honest conclusion. The password survived everything I threw at it. My original lockout was almost certainly a wrong or mistyped wizard password — the reboot that came with the IP change simply expired the still-live session (which is why I’d happily browsed my sites right after restoring) and forced a re-login that then failed. MongoDB and SMTP were dead ends because they touch the wrong layer. The reset worked because it overwrote a password I never actually knew.
The real lesson: a local UniFi OS account has no self-service recovery, so a single wrong password locks you out for good — and the recipe above is the way back, whatever put you there.
Security
This is legitimate recovery for a server you own and control, and it already requires root on the host — it grants no access that root didn’t already have. It’s account recovery, not a vulnerability. Don’t point it at machines that aren’t yours.
References & credits
The ulp-go."user" / argon2id location wasn’t something I reverse-engineered from scratch — the decisive pointer came from Andy Sturniolo’s write-up below. Everything else is corroboration, official docs, and the classic-but-wrong-product guides worth knowing to avoid.
- Andy Sturniolo — “I Got Locked Out of UniFi OS Server on My Mac. Every Password Reset Guide Was Wrong.” (primary source) — Identifies the
ulp-goPostgres DB, the"user"table, and the argon2id parameters. The method this article builds on. - Ubiquiti Help Center — “UniFi Password Recovery and Ownership Transfer” — Official recovery paths; local credentials are unrecoverable without a UI.com account or a pre-configured SMTP server.
- HostiFi — “Configuring SMTP for UniFi alerts and password resets” — Practical SMTP setup that enables the built-in “Forgot Password” flow.
- Truvis Thornton — “UniFi Controller forgot password reset via MongoDB (SSH/CLI)” — The classic
ace.admin.x_shadowreset — correct for the standalone Network Application, and the method this article warns is for the wrong product on UniFi OS. - noble-hashes (Paul Miller) — The audited pure-JS argon2 implementation that
unifi-corebundles and this method reuses in place.
Scripts (the packaged reset tool with preflight + rollback) are published here: gitlab.com/miguelcalidade/unifi-os-password-reset.
Field report · UniFi OS Server 10.5 · unifi-core 5.1.132 · podman on Ubuntu Server LTS. Verified end-to-end; verify against your own version before running.
Investigated, tested on a live lab box, and written up with Claude (Anthropic) pair-debugging over SSH.

