Skip to content

Instantly share code, notes, and snippets.

@mapster
Last active May 20, 2026 20:03
Show Gist options
  • Select an option

  • Save mapster/4b8b9f8f6b92cc1ca58ae5506e0508f7 to your computer and use it in GitHub Desktop.

Select an option

Save mapster/4b8b9f8f6b92cc1ca58ae5506e0508f7 to your computer and use it in GitHub Desktop.
Export Google Authenticator secret OTP-keys

Export Google Authenticator secret OTP-keys

I recently got myself a Yubikey and wanted to set up the Yubico Authenticator with all the OTPs I had in Google Authenticator. Unfortunately Yubico Authenticator doesn't support scanning the QR-code that the Google Authenticator generates when you export the OTP-keys, and it seemed like quite the daunting task to log in to every service to generate new OTP-keys. So I decided to have a look at the contents of the QR code, to see if I could import the keys into Yubico Authenticator in one go. Luckily I found a blog post by Alex Bakker that describes the data format.

Transfer QR-code to computer

Unfortunately, but likely for the best, the security policy of Google Authenticator won't allow you to take a screenshot of the generated export-all QR-code. Since my phone is also the only device I own with a decent camera, I had to resign to snap a picture of QR-code on the phone screen using the built-in webcam of my laptop. If you also use a low quality camera you might run into the same issue that I did, namely that the picture will have too much noice for QR-code readers to interpret the QR-code. The easiest way around it was split the export into multiple QR-codes, which for me meant two codes instead of twenty. I used the Linux desktop app Kamoso to snap the pictures.

Extract OTP-keys

To extract the OTP-keys from the Google Authenticator QR-code is a four-step procedure:

  1. Extract data-URL from the QR-code
  2. Base64 Decode the query parameter data
  3. Decode the protobuf message
  4. For each OTP-key; base32 decode the secret field

Requirements

  • nodejs
  • zbar-tools

The zbar-tools package includes a tool to extract URLs from QR-codes. I did try to use jimp and qrcode-reader in the javascript, but it didn't work straight out the box so I didn't bother spending more time to get it to work.

Usage

  1. Download the files package.json, index.js, migration-payload.proto and otp-codes.sh to an empty directory
  2. Make otp-codes.sh executable: chmod +x otp-codes.sh
  3. Extract codes ./otp-codes.sh <path to qr-code image>
const protobuf = require("protobufjs");
const fs = require('fs');
const base32 = require('hi-base32');
async function decodeMessage(buffer) {
const root = await protobuf.load("migration-payload.proto");
const payload = root.lookupType("MigrationPayload");
const err = payload.verify(buffer);
if (err) {
throw err;
}
const message = payload.decode(buffer);
const obj = payload.toObject(message);
return payload.toObject(message);
}
async function printOTPCodes(otpBuffer) {
const payload = await decodeMessage(otpBuffer);
const otpArray = payload.otpParameters;
for(let i = 0; i < otpArray.length; i++) {
const otp = otpArray[i];
console.log("Issuer: " + otp.issuer);
console.log("Name: " + otp.name);
console.log("Secret: " + base32.encode(otp.secret));
console.log("-----------------------------------");
}
}
const url = new URL(process.argv[2]);
const otpBuffer = Buffer.from(url.searchParams.get('data'), 'base64');
printOTPCodes(otpBuffer).catch(err => console.error(err));
syntax = "proto3";
message MigrationPayload {
enum Algorithm {
ALGO_INVALID = 0;
ALGO_SHA1 = 1;
}
enum OtpType {
OTP_INVALID = 0;
OTP_HOTP = 1;
OTP_TOTP = 2;
}
message OtpParameters {
bytes secret = 1;
string name = 2;
string issuer = 3;
Algorithm algorithm = 4;
int32 digits = 5;
OtpType type = 6;
int64 counter = 7;
}
repeated OtpParameters otp_parameters = 1;
int32 version = 2;
int32 batch_size = 3;
int32 batch_index = 4;
int32 batch_id = 5;
}
#!/bin/bash
qrcode="$(zbarimg $1 2>/dev/null)"
url="${qrcode/#QR-Code:}"
echo "Parsing: $url"
echo "-----------------------"
echo ""
node index.js "$url"
{
"name": "google-authenticator-otp-key-extractor",
"version": "1.0.0",
"description": "",
"main": "index.js",
"author": "Alexander Hoem Rosbach <[email protected]>",
"license": "MIT",
"dependencies": {
"hi-base32": "^0.5.0",
"protobufjs": "^6.10.1"
}
}
@max-berman
Copy link
Copy Markdown

Works like a charm, Thank you!

@jcconnell
Copy link
Copy Markdown

Worked well for me also! Thank you.

Probably should add npm install as step 2 to the usage section.

@milansav
Copy link
Copy Markdown

milansav commented Aug 4, 2022

You are a legend! Thank you!

@scarlion1
Copy link
Copy Markdown

I'm trying to run through these steps manually, and when I decode the protobuf message, it looks like my secrets are still encoded or encrypted in different ways. For example I have some output that looks like:

otp_parameters {
secret: "\123\456\789\012"
}

while others look like:
otp_parameters {
secret: "ABC12E2F33GH4"
}

while still others look like:
otp_parameters {
secret: "RwX&pUe*QLn#9BPWGRQUmHP#T"
}

None of the secrets can be base32 decoded so I'm stuck. Any idea what's happened?

@msm-code
Copy link
Copy Markdown

Yes. You should base32 encode them, not decode. I.e. these are raw bytes, and you must base32 encode them to make them printable

@thomasmarwitz
Copy link
Copy Markdown

Thanks for your gist! I made this web version out of it: https://thomasmarwitz.github.io/tools/artifacts/extract-otp-secrets.html
(if you don't want to install tools)

@pablodgonzalez
Copy link
Copy Markdown

pablodgonzalez commented May 9, 2025

Some little fixes tested in ubuntu for zbarimg 0.23.93

#!/bin/bash

qrcode="$(zbarimg -q --raw "$1" 2>/dev/null)"

echo "Parsing: $qrcode"
echo "-----------------------"
echo ""
node index.js "$qrcode"

@nicopunto
Copy link
Copy Markdown

Thanks for your gist! I made this web version out of it: https://thomasmarwitz.github.io/tools/artifacts/extract-otp-secrets.html
(if you don't want to install tools)

This is absolutely brilliant! Thank you so much!

@junedkhatri31
Copy link
Copy Markdown

junedkhatri31 commented Oct 28, 2025

@leocb
Copy link
Copy Markdown

leocb commented Nov 5, 2025

Thank you! As an alternative to all the awesome tools others created, I made a CyberChef recipe based on your original steps.
Open the recipe in this link: CyberChef "Decode Google TOTP Export" Recipe

Then follow these steps:

  1. You have to get the text from the Google TOTP Export QRCode using a mobile scanner app with another phone
  2. Paste the QRCode text on the input of CyberChef
  3. Your Base32 Key is now available on the output window of CyberChef

@MicahTa
Copy link
Copy Markdown

MicahTa commented Jan 18, 2026

All of you guys are fucking studs

@LouDnl
Copy link
Copy Markdown

LouDnl commented Jan 19, 2026

Thanks for this!

@dnstelecom
Copy link
Copy Markdown

One more...

python3 -m pip install opencv-python
python3 ga_qr_to_secrets.py qr1.jpg

ga_qr_to_secrets.py:

#!/usr/bin/env python3
import argparse
import base64
import binascii
import json
import sys
from urllib.parse import quote, urlencode, unquote

try:
    import cv2
except ImportError:
    cv2 = None


ALGORITHMS = {
    0: "SHA1",
    1: "SHA1",
    2: "SHA256",
    3: "SHA512",
    4: "MD5",
}

DIGITS = {
    0: 6,
    1: 6,
    2: 8,
}

TYPES = {
    1: "hotp",
    2: "totp",
}


def unique(items):
    seen = set()
    out = []

    for item in items:
        if item and item not in seen:
            seen.add(item)
            out.append(item)

    return out


def read_varint(buf: bytes, pos: int) -> tuple[int, int]:
    value = 0
    shift = 0

    while True:
        if pos >= len(buf):
            raise ValueError("truncated protobuf varint")

        b = buf[pos]
        pos += 1
        value |= (b & 0x7F) << shift

        if not (b & 0x80):
            return value, pos

        shift += 7

        if shift > 70:
            raise ValueError("protobuf varint is too long")


def read_fields(buf: bytes):
    pos = 0

    while pos < len(buf):
        key, pos = read_varint(buf, pos)
        field_no = key >> 3
        wire_type = key & 7

        if wire_type == 0:
            value, pos = read_varint(buf, pos)

        elif wire_type == 1:
            if pos + 8 > len(buf):
                raise ValueError("truncated fixed64 field")

            value = buf[pos:pos + 8]
            pos += 8

        elif wire_type == 2:
            length, pos = read_varint(buf, pos)

            if pos + length > len(buf):
                raise ValueError("truncated length-delimited field")

            value = buf[pos:pos + length]
            pos += length

        elif wire_type == 5:
            if pos + 4 > len(buf):
                raise ValueError("truncated fixed32 field")

            value = buf[pos:pos + 4]
            pos += 4

        else:
            raise ValueError(f"unsupported protobuf wire type: {wire_type}")

        yield field_no, wire_type, value


def int32(value: int) -> int:
    value &= 0xFFFFFFFF
    return value - 0x100000000 if value >= 0x80000000 else value


def extract_payload_bytes(qr_text: str) -> bytes:
    s = qr_text.strip()

    if s.startswith("QR-Code:"):
        s = s[len("QR-Code:"):].strip()

    if s.startswith("otpauth-migration://"):
        marker = "data="
        i = s.find(marker)

        if i < 0:
            raise ValueError("otpauth-migration URI has no data= parameter")

        data = s[i + len(marker):].split("&", 1)[0].split("#", 1)[0]

        # Важно: unquote(), а не unquote_plus().
        # Символ '+' валиден для Base64 и не должен превращаться в пробел.
        data = unquote(data)
    else:
        # Можно передать только значение data=, без всего URI.
        data = s

    data = "".join(data.split())
    data += "=" * (-len(data) % 4)

    try:
        return base64.b64decode(data, validate=True)
    except binascii.Error:
        return base64.urlsafe_b64decode(data)


def parse_otp_parameters(msg: bytes) -> dict:
    account = {
        "name": "",
        "issuer": "",
        "algorithm": "SHA1",
        "digits": 6,
        "type": "totp",
        "counter": 0,
    }

    secret_raw = b""

    for field_no, wire_type, value in read_fields(msg):
        if field_no == 1 and wire_type == 2:
            secret_raw = value

        elif field_no == 2 and wire_type == 2:
            account["name"] = value.decode("utf-8", "replace")

        elif field_no == 3 and wire_type == 2:
            account["issuer"] = value.decode("utf-8", "replace")

        elif field_no == 4 and wire_type == 0:
            account["algorithm"] = ALGORITHMS.get(value, f"UNKNOWN_{value}")

        elif field_no == 5 and wire_type == 0:
            account["digits"] = DIGITS.get(value, value)

        elif field_no == 6 and wire_type == 0:
            account["type"] = TYPES.get(value, f"unknown_{value}")

        elif field_no == 7 and wire_type == 0:
            account["counter"] = value

    if not secret_raw:
        raise ValueError("OTP entry has no secret field")

    account["secret"] = base64.b32encode(secret_raw).decode("ascii").rstrip("=")

    return account


def parse_migration_payload(payload: bytes) -> tuple[dict, list[dict]]:
    meta = {}
    accounts = []

    for field_no, wire_type, value in read_fields(payload):
        if field_no == 1 and wire_type == 2:
            accounts.append(parse_otp_parameters(value))

        elif wire_type == 0:
            name = {
                2: "version",
                3: "batch_size",
                4: "batch_index",
                5: "batch_id",
            }.get(field_no, f"field_{field_no}")

            meta[name] = int32(value)

    return meta, accounts


def make_otpauth_uri(account: dict) -> str:
    label = account["name"]
    issuer = account.get("issuer") or ""

    if issuer and not label.startswith(issuer + ":"):
        label = f"{issuer}:{label}"

    params = {
        "secret": account["secret"],
        "algorithm": account["algorithm"],
        "digits": str(account["digits"]),
    }

    if issuer:
        params["issuer"] = issuer

    if account["type"] == "hotp":
        params["counter"] = str(account.get("counter", 0))
    else:
        params["period"] = "30"

    return (
        f"otpauth://{account['type']}/"
        f"{quote(label, safe='')}?"
        f"{urlencode(params, quote_via=quote)}"
    )


def decode_qr_strings_from_image(path: str) -> list[str]:
    if cv2 is None:
        raise RuntimeError("Install OpenCV first: python3 -m pip install opencv-python")

    img = cv2.imread(path)

    if img is None:
        raise RuntimeError(f"Cannot read image: {path}")

    detector = cv2.QRCodeDetector()

    candidates = [img]

    try:
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        candidates.append(gray)

        for scale in (2, 3):
            candidates.append(
                cv2.resize(
                    gray,
                    None,
                    fx=scale,
                    fy=scale,
                    interpolation=cv2.INTER_CUBIC,
                )
            )
    except Exception:
        pass

    decoded = []

    for candidate in candidates:
        try:
            ok, infos, _points, _straight = detector.detectAndDecodeMulti(candidate)

            if ok:
                decoded.extend([x for x in infos if x])
        except Exception:
            pass

        try:
            text, _points, _straight = detector.detectAndDecode(candidate)

            if text:
                decoded.append(text)
        except Exception:
            pass

    return unique(decoded)


def decode_qr_strings_from_camera(camera_index: int) -> list[str]:
    if cv2 is None:
        raise RuntimeError("Install OpenCV first: python3 -m pip install opencv-python")

    cap = cv2.VideoCapture(camera_index)

    if not cap.isOpened():
        raise RuntimeError(f"Cannot open camera index {camera_index}")

    detector = cv2.QRCodeDetector()
    decoded = []

    print(
        "Show the Google Authenticator export QR to the webcam. "
        "If there are multiple QR codes, show them one by one. "
        "Press q or Esc when done.",
        file=sys.stderr,
    )

    try:
        while True:
            ok, frame = cap.read()

            if not ok:
                break

            found = []

            try:
                ok_multi, infos, _points, _straight = detector.detectAndDecodeMulti(frame)

                if ok_multi:
                    found.extend([x for x in infos if x])
            except Exception:
                pass

            try:
                text, _points, _straight = detector.detectAndDecode(frame)

                if text:
                    found.append(text)
            except Exception:
                pass

            for item in unique(found):
                if item not in decoded:
                    decoded.append(item)
                    print(f"Scanned QR #{len(decoded)}", file=sys.stderr)

            cv2.imshow("Google Authenticator export QR scanner", frame)
            key = cv2.waitKey(1) & 0xFF

            if key in (ord("q"), 27):
                break

    finally:
        cap.release()
        cv2.destroyAllWindows()

    return decoded


def print_accounts(accounts: list[dict], json_mode: bool) -> None:
    if json_mode:
        print(json.dumps(accounts, ensure_ascii=False, indent=2))
        return

    for i, account in enumerate(accounts, start=1):
        title = account["name"]

        if account.get("issuer"):
            title = f"{account['issuer']} / {title}"

        print(f"[{i}] {title}")
        print(f"    secret:    {account['secret']}")
        print(f"    type:      {account['type'].upper()}")
        print(f"    algorithm: {account['algorithm']}")
        print(f"    digits:    {account['digits']}")

        if account["type"] == "hotp":
            print(f"    counter:   {account['counter']}")

        if "otpauth_uri" in account:
            print(f"    uri:       {account['otpauth_uri']}")

        print()


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Extract Base32 secrets from Google Authenticator export QR codes."
    )
    parser.add_argument(
        "images",
        nargs="*",
        help="local photos/screenshots of Google Authenticator export QR codes",
    )
    parser.add_argument(
        "--camera",
        type=int,
        default=None,
        help="scan QR codes live from webcam, usually --camera 0",
    )
    parser.add_argument(
        "--stdin",
        action="store_true",
        help="read already decoded QR text from stdin",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="print JSON",
    )
    parser.add_argument(
        "--otpauth",
        action="store_true",
        help="also print otpauth:// URI for each account",
    )

    args = parser.parse_args()

    qr_texts = []

    if args.camera is not None:
        qr_texts.extend(decode_qr_strings_from_camera(args.camera))

    for image_path in args.images:
        qr_texts.extend(decode_qr_strings_from_image(image_path))

    if args.stdin or not sys.stdin.isatty():
        qr_texts.extend(
            line.strip()
            for line in sys.stdin.read().splitlines()
            if line.strip()
        )

    qr_texts = unique(qr_texts)

    if not qr_texts:
        print("No QR payloads decoded.", file=sys.stderr)
        return 2

    all_accounts = []

    for qr_index, qr_text in enumerate(qr_texts, start=1):
        try:
            payload = extract_payload_bytes(qr_text)
            meta, accounts = parse_migration_payload(payload)
        except Exception as e:
            print(f"QR #{qr_index}: cannot parse as Google Authenticator export: {e}", file=sys.stderr)
            continue

        for account in accounts:
            account["source_qr"] = qr_index

            if meta:
                account["export_meta"] = meta

            if args.otpauth:
                account["otpauth_uri"] = make_otpauth_uri(account)

            all_accounts.append(account)

    if not all_accounts:
        print("No accounts extracted.", file=sys.stderr)
        return 1

    print_accounts(all_accounts, args.json)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment