Migrate from OTP Auth (iOS) to 2FAS, by way of an Aegis JSON vault.
2FAS cannot read OTP Auth's encrypted .otpauthdb backup, but it can import an
unencrypted Aegis vault. This
script decrypts the backup and writes that vault, in one step, with no QR codes
in between. The same file also imports into Aegis itself, Bitwarden
Authenticator (Android) and cotp, so nothing here is tied to 2FAS.
Decryption is done by wenceslas-sanchez/decrypt-otpauth, which this script imports as a library, pinned to a commit. Everything here is the conversion layer on top. macOS and Linux only.
Clone the repository (the lock file has to sit next to the script) and run it
with uv. Dependencies are declared inline
(PEP 723) and locked in otpauth_to_2fas.py.lock, so there is nothing to
install beyond uv itself:
uv run --locked otpauth_to_2fas.py -p Accounts.otpauthdb -o aegis_plain.jsonYou will be prompted for the password you set when exporting the backup from OTP Auth (Settings -> Backup).
The first run fetches the dependencies, and a Python 3.13 or newer if none is installed. To convert with the network off, fetch first and then run offline:
uv sync --locked --script otpauth_to_2fas.py
uv run --offline --locked otpauth_to_2fas.py -p Accounts.otpauthdb -o aegis_plain.jsonWithout uv (needs Python 3.13 or newer, and git):
python3.13 -m venv .venv && . .venv/bin/activate
pip install "git+https://github.com/wenceslas-sanchez/decrypt-otpauth@f99357ac8524c4b4b8e85e8abdd76aeececc9295"
python otpauth_to_2fas.py -p Accounts.otpauthdb -o aegis_plain.json| Flag | Effect |
|---|---|
-p, --path |
the encrypted .otpauthdb backup (required) |
-o, --output |
output path, or - for stdout (default aegis_plain.json) |
--uris FILE |
also write one otpauth:// URI per line, for apps that import URIs |
--steam |
write 5-digit, 30-second SHA1 accounts whose issuer or name says "Steam" as Aegis type steam |
--folder-notes |
record the OTP Auth folder name in each entry's note (Aegis shows it, 2FAS mostly ignores it) |
--force |
replace existing output files |
Output files are created with mode 0600. Either every output file is written
or none is. With --force the previous files are removed first and then
recreated, so the result never keeps an old mode, a symlink, or a hard link. An
output path may not be the backup itself.
Exit codes:
| Code | Meaning |
|---|---|
| 0 | every account was converted |
| 1 | nothing was written to disk: unreadable backup, wrong password, or an output that was refused or failed |
| 2 | bad command line |
| 3 | some accounts were skipped; the output is written and the skipped accounts are listed |
| 130 | cancelled with Ctrl-C |
- 2FAS: Settings -> Import Tokens (Import/Export in older versions) -> Aegis, then pick the JSON. See the 2FAS import guide.
- Aegis: Settings -> Import & Export -> Import from file -> Aegis.
2FAS reports how many tokens it found and how many it will import. Compare that with the count the script printed, and verify a few codes against the old app before deleting anything.
The output is every one of your OTP secrets in cleartext.
- Write it somewhere that is neither synced nor backed up. On a Mac,
~/Desktopand~/Documentsare in iCloud Drive when Desktop & Documents sync is on, and Time Machine snapshots the rest. - Use
-o FILErather than redirecting-o -into a file: the shell creates a redirected file with your umask, not with mode 0600. With-o -on a terminal the secrets also land in the scrollback; clear it. - Move it to the phone by AirDrop or a USB cable rather than mail or a cloud drive.
- Delete it on the Mac and on the phone, including the Files app's Recently
Deleted folder. Overwrite-style tools (
shred,rm -P) do not reliably destroy old blocks on APFS or SSDs; full-disk encryption (FileVault, LUKS) is the real protection. - Keep the encrypted
.otpauthdbas your fallback, not the JSON.
- Accounts that no target app could use are skipped and listed on stderr: unknown OTP type, MD5 algorithm (Aegis and 2FAS for iOS silently treat it as SHA1, which generates wrong codes, and 2FAS for Android drops it), empty secret, non-positive period, digit count outside 1 to 10, negative HOTP counter.
- 2FAS only imports 5 to 8 digits and periods of 10, 30, 60 or 90 seconds, and
keeps one entry per secret. The summary marks entries that hit these limits
with
(!); re-add those by hand. - 2FAS has a reported Aegis import bug (twofas/2fas-android#130) affecting entries that share a secret, or whose names contain brand strings. Count your entries after importing.
- OTP Auth stores no issuer on some accounts. Those get an empty issuer in both
the JSON and the
--urisfile. 2FAS names the service after the issuer, so such entries show up under the account name only. - Steam tokens are 5-digit TOTP with a custom alphabet.
--steamwrites them as Aegis typesteam, which both 2FAS and Aegis accept; check the code against the Steam app. In the--urisfile they stayotpauth://totp/withdigits=5and the account's own issuer. - The upstream reader validates the OTP type and algorithm codes while it decodes the backup, so an unknown code in any single account makes the whole backup unreadable. The error names the offending value.
- The summary printed to stderr lists issuers and account names, never secrets.
uv run --locked tests/test_otpauth_to_2fas.pyThe tests build real encrypted .otpauthdb files, so every case runs end to end
through the upstream decryptor. tests/data/Accounts.otpauthdb is the test
export shipped by decrypt-otpauth (password hello).
GPL-3.0, matching the upstream decryption library this script imports. Copyright (C) 2026 Wonjoon Seol.