2FAuth 9.0.0 Tested: Ente Import Needs a Decrypted File

2FAuth 9.0.0 reads Ente Auth exports, but only the decrypted text file. I tested the import, the rate limit and the upgrade from 8.0.3.

2FAuth 9.0.0 Tested: Ente Import Needs a Decrypted File

2FAuth 9.0.0 came out on 27 October 2026, and the item that will change some people's week sits near the bottom of the list: it now imports Ente Auth exports. I pulled the image this morning and threw real requests at it on my own server. The import works, but it wants the decrypted file, and Ente's default export is encrypted.

What is actually new in 9.0.0

The v9.0.0 release notes list four changes you'll see:

  • A table layout for desktop screens.
  • Favourites, so you can mark accounts regardless of which group they sit in.
  • Quick-select buttons for move, unshare, delete and export.
  • Support for the Ente Auth export format.

Two fixes ship with it. Issue #571 covered large API responses being truncated when the container runs as non-root. Issue #575 was a composer advisory block that broke setup. The fix for #571 is in commit a7c4517, and I'll come back to it.

For context, v8.0.3 (22 October) was an image refresh to PHP 8.4.27 for GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp, and v8.0.2 was 2 September. The 9.0.0 image is 214 MB against 211 MB, and Laravel moves from 13.29.0 to 13.38.0. Both bundle nginx 1.30.4 with PHP-FPM and run as uid 1000. The project is AGPL-3.0, had 4,211 GitHub stars when I looked, and is still one container on SQLite.

My box: 8 vCPU, 23 GiB RAM, Linux kernel 6.8, plain Docker. I created a fresh named volume, chowned it to 1000, and 9.0.0 was ready in 9 seconds.

The Ente import needs the decrypted file

Ente's export guide documents an encrypted JSON backup. That's the file most people will grab first. I sent it to the migration endpoint, then sent the decrypted version:

# encrypted export: version, kdfParams, encryptedData, encryptionNonce
POST /api/v1/twofaccounts/migration
HTTP 400
{"message":"Data do not match any supported format"}

# decrypted export: one otpauth:// URI per line
POST /api/v1/twofaccounts/migration
HTTP 200   (3 of 3 accounts parsed)

Ente's CLI produces the plain-text file in one line:

ente auth decrypt backup.json --output codes.txt

All three test accounts came through, including a Steam URI that 2FAuth correctly turned into a steamtotp entry with 5 digits. The app's own supported-formats table lists Ente Auth under "Plain text" only and warns you not to set a password or encryption on the export. So it's documented. It's also easy to miss. Delete codes.txt after the import, because it holds every secret you own in clear text.

The numbers I measured

I scripted account creation and list calls through the API with a personal access token.

TestResult
List payload, 80 accounts35,032 bytes
List payload, 140 accounts61,353 bytes
List payload, 240 accounts105,253 bytes
Create one account, no iconabout 5.4 s
Create one account, dummy iconabout 0.3 s
Create 100 accounts, no icons152 s
60 requests inside one minuteHTTP 429
5,000-line importHTTP 504 after 60 s

The payload grows in a straight line, about 437 bytes per account. The rate limit is 60 requests per minute, and the 429 body was {"message":"Too Many Attempts."}. The icon figure surprised me most. With no icon field, the server goes looking for a default icon over the network on every create.

The 5,000-line import never finished. nginx gave up with a 504 after 60 seconds, the PHP execution timeout. That was on an 8 vCPU box with icon lookups running, so your ceiling will differ. The answer is still to import in chunks.

Two quick checks. I recorded an account with a known secret and called GET /api/v1/twofaccounts/{id}/otp. It returned 913625, and my own HMAC-SHA1 TOTP in Python gave 913625 too. A token from POST /oauth/personal-access-tokens comes back with expiresIn: 31536000, which is a year. Set a reminder if a script depends on one.

The Docker permission is still not where the config points

The #571 work concerns where nginx writes FastCGI temp files when the container runs as non-root. I ran the same probe in both images as uid 1000:

# 8.0.3
touch /var/lib/nginx/tmp/fastcgi/_probe   # Permission denied (nginx:root, 0700)

# 9.0.0, nginx config now has:
fastcgi_temp_path /tmp/fastcgi_temp 1 2;
touch /tmp/fastcgi_temp/_probe            # Permission denied (nginx:root, 0700)

The config line moved. The ownership didn't, so the new path is as unwritable for uid 1000 as the old one.

Now the honest bit. I couldn't reproduce the truncation itself. With 240 accounts I got a complete 105,253-byte JSON body from a plain request, from a client throttled to 1 KB/s over 60 seconds, and with a 2 KB receive window. Nothing was cut short and no [crit] line showed up in the container log. The precondition is present. The symptom didn't appear on this box.

The uid is a harder limit. Start the container as anything other than 1000 and it dies at once:

$ docker run --user 1001:1001 2fauth/2fauth:9.0.0
/bin/sh: can't open '/usr/local/bin/entrypoint.sh': Permission denied

The entrypoint is mode 0500 and owned by uid 1000. Platforms that assign a random uid, OpenShift style, can't use the image as shipped. If you've fought this kind of error on volumes before, my Docker permission denied guide covers that side.

The upgrade was boring, which is the good news

I stopped the 8.0.3 container and started 9.0.0 on the same volume. The log said:

Installed commit 3516c77 is different from program commit 1aa53cf, we are migrating...
INFO  Running migrations.

It was ready in 5 seconds. All 240 accounts were still there (the list was now 110,053 bytes), and a generated code still matched my independent calculation.

What I would do

  1. Upgrade this week and pin the tag to 2fauth/2fauth:9.0.0.
  2. Run as uid 1000 and chown the volume to 1000 before the first start. The official Docker CLI guide has the base command.
  3. Back up /2fauth/database.sqlite before switching tags. The log shows it symlinked from /srv/database/database.sqlite, so copy the real file on the volume.
  4. Import in chunks, stay under 60 requests a minute, and send an icon value from scripts.
  5. Keep your APP_KEY. If you rotate it, the old key has to go into APP_PREVIOUS_KEYS.

My take

I'd upgrade. If you're leaving Ente for something you host yourself, the import alone justifies it, and the migration did its job without me touching anything. I'd still like the temp directory ownership fixed in a point release, even though I couldn't break it. For one container holding all my 2FA secrets, a boring upgrade is the best review I can give.

I ran these tests on my own server on 2 November 2026, with the commands you can see above, and wrote this up with AI help. Every number came out of my own logs.

Frequently asked questions

Can 2FAuth 9.0.0 import an encrypted Ente Auth backup?

No. The encrypted JSON returned HTTP 400 in my test, so run ente auth decrypt backup.json --output codes.txt first and import the text file.

Is upgrading from 8.0.3 to 9.0.0 safe?

It was for me. Migrations ran automatically on the same volume and all 240 accounts survived, but back up the SQLite file first anyway.

Can I run the 2FAuth container as a different uid?

Not with the image as shipped. The entrypoint is mode 0500 and owned by uid 1000, so --user 1001:1001 failed immediately.

Why does a bulk import fail with 429 or 504?

The API allows 60 requests per minute, and a 5,000-line import hit a 60-second timeout on my box. Split the import into chunks and pass an icon value to skip the slow lookup.