X
    Categories: Unifi

Recovering UniFi OS Access.

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:

LayerWhat it isWhere its users live
UniFi OS console (:11443)The OS login — the thing you’re locked out ofpostgres · ulp-go."user"
Network applicationSites, devices, WLANsmongodb · 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-core Postgres DB — has user_settingsuser_certificates… but no password column anywhere.
  • config/cache/users.json — has the account (username: admin, empty email) but no password field, only a password_revision counter. And the path says cache/: 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$&lt;base64-salt>$&lt;base64-hash>


Parameters: memory 65536 KiB, time cost 1, parallelism 5, output length 32, version 0x13ulp-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", then POST to /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 by only_local_account. That flag is a red herring: a normally created local owner shows only_local_account = f and 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 always id = 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' &lt;&lt;'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_account is a red herring. A normal local owner reads f; 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.

Stageunique_idpassword fingerprint (md5)login
Fresh installa2d45f96…45eb2ca4…works
After restorea2d45f96…45eb2ca4…works
After IP changea2d45f96…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.

  1. Andy Sturniolo — “I Got Locked Out of UniFi OS Server on My Mac. Every Password Reset Guide Was Wrong.” (primary source) — Identifies the ulp-go Postgres DB, the "user" table, and the argon2id parameters. The method this article builds on.
  2. 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.
  3. HostiFi — “Configuring SMTP for UniFi alerts and password resets” — Practical SMTP setup that enables the built-in “Forgot Password” flow.
  4. Truvis Thornton — “UniFi Controller forgot password reset via MongoDB (SSH/CLI)” — The classic ace.admin.x_shadow reset — correct for the standalone Network Application, and the method this article warns is for the wrong product on UniFi OS.
  5. noble-hashes (Paul Miller) — The audited pure-JS argon2 implementation that unifi-core bundles 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.

luzem:
Related Post