Extract the secret from Google Authenticator and generate OTP with NodeJS

I had to integrate 2FA with OTP inside a suite for integration tests in NodeJS. The users for manual testing in the target environment used a regular authenticator, so that was my starting point.

OTP Authenticators work all in the same way: they implement an algorithm that calculates the OTP based on a secret shared between client and server. It is also time-dependent, but this detail is not relevant for this activity. I had to extract the secret from the authenticator and use a library to do the rest of the job.

The complete code is in my GitHub: google-authenticator-commandlin.

Extract the secret from the authenticator

This process depends on the authenticator. For some, like 2FAS, it’s as simple as opening the details of a record. For Google Authenticator, it’s more complex because Google uses a particular format and (at this time at least) there is no easy way to extract the secret.

Extract the secret from Google Authenticator

Open Google Authenticator -> hamburger icon -> transfer accounts -> select only one account -> generate the QR Code -> save the image with the cropped QR code inside a folder.

Run the following script with node decode-qr-code.js qr.jpeg to decode the QR code.

const QrCode = require('qrcode-reader');
async function decodeQRCode(path) {
  const jimpModule = await import('jimp');
  // The main class is under jimpModule.Jimp
  const JimpClass = jimpModule.Jimp;
  if (!JimpClass || !JimpClass.read) {
    throw new Error('Jimp.read function not found in imported module');
  }
  const img = await JimpClass.read(path);
  return new Promise((resolve, reject) => {
    const qr = new QrCode();
    qr.callback = (err, value) => {
      if (err) reject(err);
      else resolve(value.result);
    };
    qr.decode(img.bitmap);
  });
}
const filePath = process.argv[2];
if (!filePath) {
  console.error('- Please provide the path to the QR code image.');
  process.exit(1);
}
decodeQRCode(filePath)
  .then(result => console.log('Decoded QR code data:\n', result))
  .catch(err => console.error('Failed to decode QR code:', err));

The output will look like this

Decoded QR code data:
 otpauth-migration://offline?data=LONG_STRING

That LONG_STRING needs to be decoded. Call the following script with node otpauth-migration.js "otpauth-migration://offline?data=LONG_STRING". This call will require the presence of otp_migration.proto in the same folder.

const protobuf = require('protobufjs');
const base32 = require('thirty-two'); // for base32 encoding
const url = require('url');
const migrationUrl = process.argv[2];
if (!migrationUrl) {
  console.error('- Please provide the otpauth-migration:// URL as the first argument.');
  process.exit(1);
}
const parsed = url.parse(migrationUrl, true);
const dataBase64 = parsed.query.data;
if (!dataBase64) {
  console.error('- No data field found in URL.');
  process.exit(1);
}
protobuf.load('otp_migration.proto').then(root => {
  const MigrationPayload = root.lookupType('MigrationPayload');
  const buffer = Buffer.from(dataBase64, 'base64');
  const message = MigrationPayload.decode(buffer);
  const object = MigrationPayload.toObject(message, { longs: String, enums: String, bytes: Buffer });
  object.otpParameters.forEach(otp => {
    // secret is a Buffer, encode it as base32
    const secretBase32 = base32.encode(otp.secret).toString().replace(/=/g, '').replace(/\n/g, '');
    console.log('Account:', otp.name);
    console.log('Issuer:', otp.issuer);
    console.log('Secret (base32):', secretBase32);
    console.log('Algorithm:', otp.algorithm);
    console.log('Digits:', otp.digits);
    console.log('Type:', otp.type);
    console.log('---------------------------');
  });
}).catch(err => {
  console.error('Failed to decode protobuf:', err);
});

otp_migration.proto

syntax = "proto3";

message MigrationPayload {
  repeated OTPParameters otp_parameters = 1;
  int32 version = 2;
  int32 batch_size = 3;
  int32 batch_index = 4;
  int32 batch_id = 5;
}

message OTPParameters {
  bytes secret = 1;
  string name = 2;
  string issuer = 3;
  Algorithm algorithm = 4;
  DigitCount digits = 5;
  OtpType type = 6;
}

enum Algorithm {
  ALGORITHM_UNSPECIFIED = 0;
  ALGORITHM_SHA1 = 1;
  ALGORITHM_SHA256 = 2;
  ALGORITHM_SHA512 = 3;
  ALGORITHM_MD5 = 4;
}

enum DigitCount {
  DIGIT_COUNT_UNSPECIFIED = 0;
  DIGIT_COUNT_SIX = 1;
  DIGIT_COUNT_EIGHT = 2;
}

enum OtpType {
  OTP_TYPE_UNSPECIFIED = 0;
  HOTP = 1;
  TOTP = 2;
}

The output will look like this:

Account: foo@bar.com
Issuer: FOO
Secret (base32): SECRET_STRING
Algorithm: ALGORITHM_SHA1
Digits: DIGIT_COUNT_SIX
Type: TOTP

That SECRET_STRING is the secret we need.

Generate the secret

Call the script with node generate-otp.js SECRET_STRING

const otplib = require('otplib');
// Get secret from command line
const secret = process.argv[2];
if (!secret) {
  console.error('- Please provide the secret as the first argument.');
  process.exit(1);
}
// Explicit TOTP configuration
otplib.authenticator.options = {
  algorithm: 'sha1',
  step: 30,     // 30-second time window
  digits: 6     // 6-digit OTP
};
// Generate TOTP
const otp = otplib.authenticator.generate(secret);
console.log(`- Secret: ${secret}`);
console.log(`- OTP: ${otp}`);

This will output the OTP. Once the secret is known, only this last step is required.

The last script can be easily integrated into any JavaScript-based integration test.

Final note

All this code was generated by AI “vibe-coding”-style. The result is not perfect, but considering that it took me about 10 minutes to do the whole thing, it’s pretty impressive.

audit pixel