Authy export explorations

This is a quick note on how I exported my encrypted 2FA keys from Authy in January 2025.

Quick background: Authy is a commercial 2FA service, which means there’s a profit-making incentive/agenda for the parent company (Twilio). Twilio/Authy has been progressively making the service more and more restrictive, such that there are very few practical ways of exporting your codes now. Authy says that this is being done in the name of security, but I think the real reason is that investors are turning the screws and soon Authy users will become money pinyatas.

I’d therefore like to get my keys out now.

I first tried using an iOS device with mitmproxy to dump the credentials, but Authy somehow detected this and responded with a message along the lines of “your device isn’t secure” when I tried to log in.

The same thing happened on a rooted Android phone – your device isn’t sufficiently secure.

Fortunately, I had a copy of Authy installed on my Macbook (it’s an iOS app).

From your home folder:

find -iname 'com.authy' Library

This may take a little while to run. You’re after something like this:

./Library/Containers/<UUID>/Data/Library/Caches/com.authy

Append fsCachedData/* and grep for “iterations”:

grep -R "iterations" ./Library/Containers/<UUID>/Data/Library/Caches/com.authy/fsCachedData/*

You’ll get a wall of text, but the first line just after the command is what matters, eg:

./Library/Containers/<UUID>/Data/Library/Caches/com.authy/fsCachedData/<UUID>

Make a temporary folder and copy the file to it:

mkdir -p ~/Projects/authy-fun
cp ./Library/Containers/<UUID>/Data/Library/Caches/com.authy/fsCachedData/<UUID> ~/Projects/authy-fun/tokens-encrypted
cd ~/Projects/authy-fun

Dump this small Python script into processor.py, set it executable, installed Python3

#!/opt/homebrew/bin/python3

import json
import base64
import binascii  # For base16 decoding
from getpass import getpass  # For hidden password input
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend


def decrypt_token(kdf_rounds, encrypted_seed_b64, salt, passphrase):
    try:
        # Decode the base64-encoded encrypted seed
        encrypted_seed = base64.b64decode(encrypted_seed_b64)

        # Derive the encryption key using PBKDF2 with SHA-1
        kdf = PBKDF2HMAC(
            algorithm=hashes.SHA1(),
            length=32,  # AES-256 requires a 32-byte key
            salt=salt.encode(),
            iterations=kdf_rounds,
            backend=default_backend()
        )
        key = kdf.derive(passphrase.encode())

        # AES with CBC mode, zero IV
        iv = bytes([0] * 16)  # Zero IV (16 bytes for AES block size)
        cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
        decryptor = cipher.decryptor()

        # Decrypt the ciphertext
        decrypted_data = decryptor.update(encrypted_seed) + decryptor.finalize()

        # Remove PKCS7 padding
        padding_len = decrypted_data[-1]
        padding_start = len(decrypted_data) - padding_len

        # Validate padding
        if padding_len > 16 or padding_start < 0:
            raise ValueError("Invalid padding length")
        if not all(pad == padding_len for pad in decrypted_data[padding_start:]):
            raise ValueError("Invalid padding bytes")

        # Extract the decrypted seed, base16 decode, and interpret as UTF-8 string
        decrypted_seed_hex = decrypted_data[:padding_start].hex()
        return binascii.unhexlify(decrypted_seed_hex).decode('utf-8')  # Decode base16 and interpret as UTF-8
    except Exception as e:
        return f"Decryption failed: {str(e)}"


def process_authenticator_data(input_file, output_file, backup_password):
    with open(input_file, "r") as json_file:
        data = json.load(json_file)

    decrypted_tokens = []
    for token in data['authenticator_tokens']:
        decrypted_seed = decrypt_token(
            kdf_rounds=1000,
            # token['key_derivation_iterations'],
            encrypted_seed_b64=token['encrypted_seed'],
            salt=token['salt'],
            passphrase=backup_password
        )
        decrypted_token = {
            "account_type": token["account_type"],
            "name": token["name"],
            "issuer": token["issuer"],
            "decrypted_seed": decrypted_seed,  # Store as UTF-8 string
            "digits": token["digits"],
            "logo": token["logo"],
            "unique_id": token["unique_id"]
        }
        decrypted_tokens.append(decrypted_token)

    output_data = {
        "message": "success",
        "decrypted_authenticator_tokens": decrypted_tokens,
        "success": True
    }

    with open(output_file, "w") as output_json_file:
        json.dump(output_data, output_json_file, indent=4)

    print(f"Decryption completed. Decrypted data saved to '{output_file}'.")


# User configuration
input_file = "tokens-encrypted"  # Replace with your input file
output_file = "decrypted_tokens.json"  # Replace with your desired output file

# Prompt for the backup password at runtime (hidden input)
backup_password = getpass("Enter the backup password: ").strip()

# Process the file
process_authenticator_data(input_file, output_file, backup_password)
chmod +x processor.py
brew install python
brew install python-cryptography

And finally, run the script (ideally after reading it):

The original script with minor changes came from Github.

./processor.py

It’ll ask for your Authy master password and write out decrypted_tokens.json

Inside decrypted_tokens.json you’ll find all your decrypted accounts, which you can use with a third-party app.

As always, these instructions may stop working, Authy may patch this, etc. No support is provided. Have fun.