Backup configuration

Configure Google Sheet sync

Use this guide to create the Google Sheet tabs, deploy the Apps Script webhook, and connect BPass Settings to your own spreadsheet.

Setup steps

  1. Create a Google Sheet with two tabs named Regular and Backup.
  2. Open Extensions, Apps Script, then paste the script below.
  3. Set BPASS_SECRET to a long random value with at least 16 characters.
  4. Deploy the Apps Script as a Web app that executes as you and is accessible to anyone with the link.
  5. Copy the deployed /exec URL into BPass Settings, enter the same webhook secret, then enable sync. BPass uses this secret to encrypt TOTP secrets before writing to the Sheet.
  6. Use Send test row before backing up real accounts, then run Backup all accounts to replace older plain-text rows.

Security checklist

  • Use a long secret and keep it out of public repos.
  • Rotate the secret if the Apps Script URL leaks.
  • Keep the Sheet private even though synced TOTP secrets are encrypted.
  • Use the same Settings secret to restore encrypted backups.
  • After rotating the secret, run a fresh full backup.
  • Use the Backup tab for full restore and Regular for live adds.

Apps Script

Paste this into Google Apps Script

const BPASS_SECRET = "paste-your-secret-here-min-16-chars";
const HEADERS = ["issuer", "label", "secret", "algorithm", "digits", "period", "createdAt"];

function json(data) {
  return ContentService
    .createTextOutput(JSON.stringify(data))
    .setMimeType(ContentService.MimeType.JSON);
}

function doPost(e) {
  try {
    const data = e.parameter || {};
    if (String(data.token || "") !== BPASS_SECRET) {
      return json({ ok: false, error: "Invalid token" });
    }

    const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
    const tabName = String(data.sheetTab || "Regular").trim();
    const sheet = spreadsheet.getSheetByName(tabName) || spreadsheet.insertSheet(tabName);
    ensureHeaders(sheet);

    if (data.action === "add") {
      sheet.appendRow([
        data.issuer || "",
        data.label || "",
        formatStoredSecret(data.secret),
        data.algorithm || "SHA1",
        Number(data.digits || 6),
        Number(data.period || 30),
        data.createdAt || new Date().toISOString(),
      ]);
      return json({ ok: true, written: 1 });
    }

    if (data.action === "backup") {
      const accounts = JSON.parse(data.accounts || "[]");
      sheet.clearContents();
      ensureHeaders(sheet);
      if (accounts.length) {
        sheet.getRange(2, 1, accounts.length, HEADERS.length).setValues(
          accounts.map(function (account) {
            return [
              account.issuer || "",
              account.label || "",
              formatStoredSecret(account.secret),
              account.algorithm || "SHA1",
              Number(account.digits || 6),
              Number(account.period || 30),
              account.createdAt || new Date().toISOString(),
            ];
          })
        );
      }
      return json({ ok: true, written: accounts.length });
    }

    if (data.action === "pull") {
      return json({ ok: true, accounts: readAccounts(sheet) });
    }

    return json({ ok: false, error: "Unknown action" });
  } catch (error) {
    return json({ ok: false, error: String(error && error.message || error) });
  }
}

function ensureHeaders(sheet) {
  const firstRow = sheet.getRange(1, 1, 1, HEADERS.length).getValues()[0];
  if (firstRow.join("") !== HEADERS.join("")) {
    sheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
  }
}

function readAccounts(sheet) {
  const values = sheet.getDataRange().getValues();
  return values.slice(1).filter(function (row) {
    return row[2];
  }).map(function (row) {
    return {
      issuer: String(row[0] || "Unknown"),
      label: String(row[1] || ""),
      secret: formatStoredSecret(row[2]),
      algorithm: String(row[3] || "SHA1"),
      digits: Number(row[4] || 6),
      period: Number(row[5] || 30),
      createdAt: row[6] ? new Date(row[6]).toISOString() : new Date().toISOString(),
    };
  });
}

function normalizeSecret(secret) {
  return String(secret || "").replace(/\s+/g, "").replace(/=+$/, "").toUpperCase();
}

function formatStoredSecret(secret) {
  const value = String(secret || "").trim();
  if (value.indexOf("bpass:v1:") === 0) return value;
  return normalizeSecret(value);
}