Inside Google Authenticator's export QR code, and why we cap a page at 10 accounts
SHORT ANSWER
What is inside Google Authenticator's export QR code?
A link of the form otpauth-migration://offline?data=… whose data is a base64-encoded protobuf message. It lists each account's secret, name, issuer, algorithm, digit count and type, and can say which page of a multi-QR export it is. The format is not officially documented; it has been worked out by the community.
Key takeaways
- The QR holds the real secrets in a thin encoding, not an encrypted one. Anyone who photographs it can generate your codes.
- The schema is community-documented, not published by Google. We read and write it with a small hand-written protobuf reader and writer.
- Large exports split into several QR codes. We cap each at 10 accounts and 1,400 bytes because dense QR codes became unreliable to scan from another phone's screen.
- You can decode a QR yourself with about 40 lines of Python. A script is below. Run it offline and delete the output.
Why this format matters
The standard way to share one 2FA account is an otpauth:// link (the Key URI Format), and it holds exactly one account. When you export several accounts from Google Authenticator, it uses a different link instead: otpauth-migration://offline?data=…. Because other apps want to let you move away from Google Authenticator, many of them, ours included, read this format.
What is in the data parameter
The data value is URL-encoded base64. Decode it and you get a protobuf message. Protobuf is a compact binary format where every value is preceded by a tag that holds a field number and a wire type (0 for a number, 2 for length-prefixed bytes). Google has not published this schema. The version below is what the community has documented and what we implemented, so treat it as reverse-engineered.
| Field | Meaning | Notes |
|---|---|---|
| 1 (repeated) | One account | A nested message, described next |
| 3 | Batch size | Number of QR codes in this export. We write it only when there is more than one page. |
| 4 | Batch index | Zero-based page number |
| Field | Meaning | Values |
|---|---|---|
| 1 | Secret | Raw bytes of the secret (not Base32) |
| 2 | Name | Account label, UTF-8 |
| 3 | Issuer | Service name, UTF-8 |
| 4 | Algorithm | 1 SHA-1, 2 SHA-256, 3 SHA-512 (0 unspecified, which we read as SHA-1) |
| 5 | Digits | 1 for six digits, 2 for eight |
| 6 | Type | 1 HOTP, 2 TOTP |
| 7 | Counter | Only for HOTP |
The community schema also lists a version field and a batch id that our writer does not emit. Apps we know of read exports without them.
Decode one yourself
This script reads an export link and prints each account. It uses only the Python standard library. Run it offline and do not paste real links into websites, because the link contains your secrets in the clear.
import base64, sys, urllib.parse
def read_varint(b, i):
shift = result = 0
while True:
byte = b[i]; i += 1
result |= (byte & 0x7F) << shift
if not byte & 0x80:
return result, i
shift += 7
def fields(b):
i = 0
while i < len(b):
tag, i = read_varint(b, i)
num, wire = tag >> 3, tag & 7
if wire == 0:
val, i = read_varint(b, i)
elif wire == 2:
n, i = read_varint(b, i); val = b[i:i+n]; i += n
else:
raise ValueError(f"unsupported wire type {wire}")
yield num, val
uri = sys.argv[1]
data = urllib.parse.parse_qs(urllib.parse.urlparse(uri).query)["data"][0]
payload = base64.b64decode(data)
ALG = {0: "unspecified", 1: "SHA1", 2: "SHA256", 3: "SHA512", 4: "MD5"}
for num, val in fields(payload):
if num == 1: # one account
acc = dict(fields(val))
print({
"issuer": acc.get(3, b"").decode(), "name": acc.get(2, b"").decode(),
"algorithm": ALG.get(acc.get(4, 0)), "digits": 8 if acc.get(5) == 2 else 6,
"type": "HOTP" if acc.get(6) == 1 else "TOTP",
"secret_base32": base64.b32encode(acc[1]).decode().rstrip("="),
})
elif num == 3: print("batch_size", val)
elif num == 4: print("batch_index", val)Test it on a harmless example: the link below holds the public RFC test secret, not a real account.
otpauth-migration://offline?data=CjcKFDEyMzQ1Njc4OTAxMjM0NTY3ODkwEhFyb2hhbkBleGFtcGxlLmNvbRoGR2l0SHViIAEoATACGAIgAA%3D%3DIt prints the issuer GitHub, the name [email protected], SHA-1, 6 digits, TOTP, and the secret GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ, plus a batch size of 2 and index 0.
Why we split exports at 10 accounts
A QR code can hold only so many bytes, and the more data it holds the more tiny squares it has. Our first export packed as many accounts into one QR as would technically fit. While testing with many accounts we found that apps often refused to read such a code from another phone's screen, even though the code was valid. The limit that matters is scanning from a screen, not encoding.
So the export now splits recursively in halves until each page has at most 10 accounts and at most 1,400 bytes of payload. A typical user has fewer than 10 accounts and gets one QR, as before. Someone with 25 gets three. The reader accepts the pages in any order and uses the batch index and size to know when it has them all.
Frequently asked questions
Can I move accounts out of Google Authenticator into HostSpica?
Yes. On the Add account screen choose Scan QR code and scan Google Authenticator's export QR. If the export has several pages, scan them one after another.
Why is the format not documented by Google?
We do not know. It is an internal convenience format that apps have had to reverse-engineer. Because it is undocumented it could change, which is why our reader ignores fields it does not recognise instead of failing.
Is it safe to show the export QR on my screen?
Only if nobody else can see or photograph it, and only for as long as you need it. After scanning, close the screen. The QR contains real secrets.
References
Review status
Last technical self-review by the author on 3 October 2026. No independent reviewer yet. If you spot an error, write to [email protected] and we will correct it and note the change.
Rohan builds HostSpica's Android apps — Authenticator, Passkey and Identity — and writes up how they work, including the mistakes along the way.
ABOUT THE PRODUCTS
RELATED