AdvancedAuthentication

Google Authenticator (TOTP)

Time-based one-time passwords with Google Authenticator or Authy.

Click to expand
Flow StartAPI POSTQuery Datauser_dataCodetotp_resultConditionIFELSEWrite DataUPDATE usersSimple OutputVerify / ErrorSimple OutputSuccess

Google Authenticator (TOTP)

Add login verification using Google Authenticator, Authy, or any authenticator app. When enabled, users must enter a 6-digit code from their app every time they sign in.

What You'll Build

A single API endpoint that handles four actions through one workflow:

Action What it does
setup_totp Generates a secret key. The frontend turns this into a QR code for the user to scan.
confirm_totp User enters the first 6-digit code from their app to confirm setup works. Enables TOTP.
verify_totp Checks the 6-digit code during login.
disable_totp Turns off TOTP and removes the secret from the user's account.

Endpoint: POST /api/v1/YOUR_ID/totp

Request body:

{
  "email": "user@example.com",
  "action": "setup_totp",
  "code": ""
}

The code field is only needed for confirm_totp and verify_totp. For setup_totp and disable_totp, send it as an empty string.

Database Setup

Add two new columns to your existing users table.

Add columns to users

Column Type Description
totp_secret TEXT The secret key (32 characters). Empty when TOTP is off.
totp_enabled TEXT "yes" when active, empty when off

No new tables needed. Everything is stored on the user row.

Workflow Flow

Flow Start (API POST)
  → Query Data (users)
  → Code (TOTP logic)
  → Condition (needsWrite?)
    ELSE → Simple Output (verify result or error)
    IF → Write Data (UPDATE users) → Simple Output (success)

This is a simple workflow - 7 nodes total.

Step 1 - Flow Start

Create a new workflow. Set the Flow Start to API mode.

Setting Value
Trigger Type API
Method POST
Custom Path totp
Rate Limit 60
Timeout 30

Step 2 - Query Data (Find User)

Connect a Query Data node to the Flow Start. This looks up the user by email so we can check their TOTP status and secret.

Output variable: user_data

Setting Value
Source users (Structured)
Columns email, name, otp_enabled, totp_secret, totp_enabled
Filter email equals {{email}}
Limit 1

Include otp_enabled alongside the TOTP columns - the verify_totp action returns this value so the frontend knows whether email OTP is also active. Make sure totp_secret and totp_enabled are included too, the Code node needs these to verify codes and check status.

Step 3 - Code Node (TOTP Logic)

Connect a Code node to the Query Data. This single node handles all four actions.

Output variable: totp_result

(function() {
  var email = variables.email;
  var action = variables.action;
  var code = variables.code;

  var userData = {{user_data}};
  var users = userData.rows || [];
  var user = users.find(function(u) { return u.email === email; });

  if (!user) {
    return { success: false, error: 'User not found', needsWrite: false };
  }

  if (!['setup_totp', 'confirm_totp', 'verify_totp', 'disable_totp'].includes(action)) {
    return { success: false, error: 'Invalid action', needsWrite: false };
  }

  // ========== TOTP VERIFICATION (HMAC-SHA1 + RFC 6238) ==========
  // Pure JS implementation - no external dependencies needed.

  function base32Decode(input) {
    var alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    var bits = '';
    for (var i = 0; i < input.length; i++) {
      var val = alphabet.indexOf(input.charAt(i).toUpperCase());
      if (val === -1) continue;
      bits += ('00000' + val.toString(2)).slice(-5);
    }
    var bytes = [];
    for (var j = 0; j + 8 <= bits.length; j += 8) {
      bytes.push(parseInt(bits.substring(j, j + 8), 2));
    }
    return bytes;
  }

  function sha1Raw(msgBytes) {
    // SHA-1 implementation operating on byte arrays
    function leftRotate(n, s) { return ((n << s) | (n >>> (32 - s))) >>> 0; }

    var h0 = 0x67452301, h1 = 0xEFCDAB89, h2 = 0x98BADCFE, h3 = 0x10325476, h4 = 0xC3D2E1F0;
    var msgLen = msgBytes.length;
    var bitLen = msgLen * 8;

    // Padding
    var padded = msgBytes.slice();
    padded.push(0x80);
    while (padded.length % 64 !== 56) padded.push(0);
    // Append length as 64-bit big-endian
    for (var s = 56; s >= 0; s -= 8) {
      padded.push((s >= 32) ? 0 : ((bitLen >>> s) & 0xff));
    }

    // Process each 512-bit block
    for (var offset = 0; offset < padded.length; offset += 64) {
      var w = [];
      for (var i = 0; i < 16; i++) {
        w[i] = ((padded[offset + i * 4] << 24) | (padded[offset + i * 4 + 1] << 16) |
                (padded[offset + i * 4 + 2] << 8) | padded[offset + i * 4 + 3]) >>> 0;
      }
      for (var i = 16; i < 80; i++) {
        w[i] = leftRotate((w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16]) >>> 0, 1);
      }

      var a = h0, b = h1, c = h2, d = h3, e = h4;
      for (var i = 0; i < 80; i++) {
        var f, k;
        if (i < 20)      { f = ((b & c) | ((~b >>> 0) & d)) >>> 0; k = 0x5A827999; }
        else if (i < 40) { f = (b ^ c ^ d) >>> 0; k = 0x6ED9EBA1; }
        else if (i < 60) { f = ((b & c) | (b & d) | (c & d)) >>> 0; k = 0x8F1BBCDC; }
        else              { f = (b ^ c ^ d) >>> 0; k = 0xCA62C1D6; }
        var temp = (leftRotate(a, 5) + f + e + k + w[i]) >>> 0;
        e = d; d = c; c = leftRotate(b, 30); b = a; a = temp;
      }
      h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0;
      h3 = (h3 + d) >>> 0; h4 = (h4 + e) >>> 0;
    }

    var result = [];
    [h0, h1, h2, h3, h4].forEach(function(h) {
      result.push((h >>> 24) & 0xff, (h >>> 16) & 0xff, (h >>> 8) & 0xff, h & 0xff);
    });
    return result;
  }

  function hmacSha1(keyBytes, msgBytes) {
    // If key > 64 bytes, hash it first
    if (keyBytes.length > 64) keyBytes = sha1Raw(keyBytes);
    // Pad key to 64 bytes
    while (keyBytes.length < 64) keyBytes.push(0);

    var ipad = [], opad = [];
    for (var i = 0; i < 64; i++) {
      ipad.push(keyBytes[i] ^ 0x36);
      opad.push(keyBytes[i] ^ 0x5c);
    }
    var inner = sha1Raw(ipad.concat(msgBytes));
    return sha1Raw(opad.concat(inner));
  }

  function generateTOTP(secretBase32, timeStep) {
    var key = base32Decode(secretBase32);
    // Convert time step to 8-byte big-endian
    var msg = [0, 0, 0, 0, 0, 0, 0, 0];
    var t = timeStep;
    for (var i = 7; i >= 0; i--) {
      msg[i] = t & 0xff;
      t = Math.floor(t / 256);
    }
    var hash = hmacSha1(key, msg);
    var offset = hash[19] & 0x0f;
    var binary = ((hash[offset] & 0x7f) << 24) | (hash[offset + 1] << 16) |
                 (hash[offset + 2] << 8) | hash[offset + 3];
    var otp = binary % 1000000;
    return ('000000' + otp).slice(-6);
  }

  function totpVerify(inputCode, secretBase32) {
    var now = Math.floor(Date.now() / 1000);
    var timeStep = Math.floor(now / 30);
    // Check current window and ±1 for clock drift
    for (var i = -1; i <= 1; i++) {
      if (generateTOTP(secretBase32, timeStep + i) === inputCode) return true;
    }
    return false;
  }

  // ========== ACTION HANDLERS ==========

  // --- SETUP TOTP ---
  if (action === 'setup_totp') {
    if (user.totp_enabled === 'yes') {
      return { success: false, error: 'TOTP is already enabled. Disable it first.', needsWrite: false };
    }
    var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    var secret = '';
    for (var i = 0; i < 32; i++) {
      secret += chars.charAt(Math.floor(Math.random() * chars.length));
    }
    return {
      success: true,
      needsWrite: true,
      email: user.email,
      totp_secret: secret,
      totp_enabled: '',
      secret: secret,
      message: 'Scan the QR code with your authenticator app'
    };
  }

  // --- CONFIRM TOTP ---
  if (action === 'confirm_totp') {
    if (!user.totp_secret || user.totp_secret === '') {
      return { success: false, error: 'No TOTP setup in progress. Run setup_totp first.', needsWrite: false };
    }
    if (user.totp_enabled === 'yes') {
      return { success: false, error: 'TOTP is already enabled', needsWrite: false };
    }
    if (!code || code.length !== 6) {
      return { success: false, error: 'Enter the 6-digit code from your authenticator app', needsWrite: false };
    }
    if (!totpVerify(code, user.totp_secret)) {
      return { success: false, error: 'Invalid code. Make sure you scanned the correct QR code.', needsWrite: false };
    }
    return {
      success: true,
      needsWrite: true,
      email: user.email,
      totp_secret: user.totp_secret,
      totp_enabled: 'yes',
      message: 'TOTP enabled successfully'
    };
  }

  // --- VERIFY TOTP ---
  if (action === 'verify_totp') {
    if (user.totp_enabled !== 'yes' || !user.totp_secret) {
      return { success: false, error: 'TOTP is not enabled', needsWrite: false };
    }
    if (!code || code.length !== 6) {
      return { success: false, error: 'Enter the 6-digit code from your authenticator app', needsWrite: false };
    }
    if (!totpVerify(code, user.totp_secret)) {
      return { success: false, error: 'Invalid authenticator code', needsWrite: false };
    }
    return {
      success: true,
      needsWrite: false,
      name: user.name,
      otp_enabled: user.otp_enabled || '',
      message: 'TOTP verified'
    };
  }

  // --- DISABLE TOTP ---
  if (action === 'disable_totp') {
    if (user.totp_enabled !== 'yes') {
      return { success: false, error: 'TOTP is not enabled', needsWrite: false };
    }
    return {
      success: true,
      needsWrite: true,
      email: user.email,
      totp_secret: '',
      totp_enabled: '',
      message: 'TOTP has been disabled'
    };
  }

  return { success: false, error: 'Invalid action', needsWrite: false };
})();

The TOTP verification is implemented inline using pure JavaScript - base32 decoding, SHA-1, HMAC-SHA1, and RFC 6238 dynamic truncation. It checks the current 30-second window and ±1 window for clock drift. No external dependencies needed.

What each action returns

Action needsWrite What happens
setup_totp true Save secret to user, return secret to frontend
confirm_totp (success) true Set totp_enabled to yes
confirm_totp (wrong code) false Return error, no changes
verify_totp (success) false Return success, no changes needed
verify_totp (wrong code) false Return error
disable_totp true Clear secret and disable
Any error false Return error, no changes

Step 4 - Condition (needsWrite?)

Connect a Condition node to the Code node. This checks whether the action needs to update the database.

Branch Condition
IF {{totp_result.data.needsWrite}} equals true → go to Step 5 (Write Data)
ELSE Go to Step 6a (Simple Output - return result directly)

The ELSE branch handles: verify_totp success, verify_totp wrong code, confirm_totp wrong code, and all error cases. These don't need any database changes.

Step 5 - Write Data (UPDATE users)

Connect a Write Data node to the IF branch of the Condition. This updates the user's TOTP columns.

Setting Value
Source users (Update)

Filter:

Column Condition Value
email equals {{totp_result.data.email}}

Columns to update:

Column Value
totp_secret {{totp_result.data.totp_secret}}
totp_enabled {{totp_result.data.totp_enabled}}

This handles three cases:

  • setup_totp: Saves the new secret, totp_enabled stays empty
  • confirm_totp: Keeps the secret, sets totp_enabled to "yes"
  • disable_totp: Clears both fields to empty

After this, connect to Step 6b (Simple Output).

Step 6a - Simple Output (ELSE branch - verify/errors)

Connect a Simple Output to the ELSE branch of the Condition.

Setting Value
Status 200
Type JSON
Output {{totp_result}}

This returns the result from the Code node directly. For verify_totp success it returns { success: true, message: "TOTP verified", name: "..." }. For errors it returns { success: false, error: "..." }.

Step 6b - Simple Output (IF branch - after Write Data)

Connect a Simple Output to the Write Data node.

Setting Value
Status 200
Type JSON
Output {{totp_result}}

This returns the result after the database has been updated. For setup_totp it includes the secret field that the frontend needs for the QR code.


Post-Import Setup

After importing the workflow JSON below:

  1. Open the Query Data node → select your users table → add columns: email, name, otp_enabled, totp_secret, totp_enabled → set the filter column to email
  2. Open the Write Data node → select your users table → set the WHERE filter column to email → map the columns: totp_secret and totp_enabled
  3. Deploy the workflow

Integration with Login

For TOTP to work during login, your login workflow needs a small change. See the User Login tutorial for the full login workflow.

If you also have email OTP enabled, see the OTP Authentication tutorial. Both methods can coexist - TOTP takes priority when both are active.

Change to the login Code node

After the password check passes, add this check before the existing OTP check:

// Check TOTP first (takes priority over email OTP)
if (user.totp_enabled === 'yes') {
  return {
    success: false,
    mobile_auth_required: true,
    message: 'Authenticator verification required'
  };
}

// Then check email OTP
if (user.otp_enabled === 'yes') {
  return {
    success: false,
    otp_required: true,
    message: 'OTP verification required'
  };
}

You also need to add totp_enabled and totp_secret to the columns in your login workflow's Query Data node.

How the full login flow works

  1. User enters email + password → clicks Sign in
  2. Login workflow checks password → sees totp_enabled = "yes" → returns { mobile_auth_required: true }
  3. Frontend shows "Enter authenticator code" input
  4. User opens Google Authenticator → reads the current 6-digit code → enters it
  5. Frontend calls /totp with { action: "verify_totp", email, code }
  6. If code is correct → user is logged in

How the setup flow works

  1. User is logged in → goes to Security settings → clicks the TOTP toggle
  2. Frontend calls /totp with { action: "setup_totp", email } → gets back a secret
  3. Frontend builds this URL: otpauth://totp/Ubex:user@email.com?secret=THE_SECRET&issuer=Ubex&digits=6&period=30
  4. Frontend renders that URL as a QR code (using any QR library)
  5. User scans the QR with Google Authenticator → the app adds "Ubex" to its list
  6. User enters the 6-digit code shown in the app
  7. Frontend calls /totp with { action: "confirm_totp", email, code } → if correct, TOTP is enabled

Issuing the JWT after TOTP Verification

Since the login endpoint doesn't issue a JWT when TOTP is enabled (it returns mobile_auth_required: true instead), you need to issue the token after successful TOTP verification.

Update the verify_totp success block in the Code node to include JWT generation:

// Inside the verify_totp success block, replace the return with:
var token = jwtSign(
  { email: user.email, name: user.name },
  '{{jwt_secret.0.key}}'
);

return {
  success: true,
  needsWrite: false,
  token: token,
  name: user.name,
  otp_enabled: user.otp_enabled || '',
  message: 'TOTP verified'
};

For this to work, the JWT secret must be stored in the Vault (Settings → Vault) with the key name my_jwt_secret. The Code node accesses it via variables.secrets.my_jwt_secret.

This is the same Vault secret used in the login workflow.


How OTP and TOTP Work Together

OTP (email codes) and TOTP (authenticator app) are two separate 2FA methods. A user can have one, both, or neither enabled. The login workflow checks them in order:

  1. Password check passes
  2. If totp_enabled === "yes" → return mobile_auth_required: true (TOTP takes priority)
  3. If otp_enabled === "yes" → return otp_required: true
  4. If neither → issue JWT directly

When both are enabled, TOTP takes priority because it doesn't require sending an email. The frontend should handle both response types:

Login response What the frontend shows
mobile_auth_required: true "Enter the 6-digit code from your authenticator app"
otp_required: true "We sent a code to your email" (and call /otp with action: "send")
success: true, token: "..." Redirect to dashboard

If a user has both enabled and wants to switch to email OTP, they need to disable TOTP first from their security settings.


Security Considerations

Feature How it works
Secret storage 32-character base32 secret stored in totp_secret column
Time window 30-second period, checks ±1 window for clock drift
Two-step setup Secret saved on setup_totp, but not active until confirm_totp succeeds
No secret re-exposure After setup, the secret is never returned to the frontend again
Clean disable disable_totp clears both totp_secret and totp_enabled

Testing

Test 1 - Setup TOTP

curl -X POST https://your-domain.com/api/v1/YOUR_ID/totp \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "action": "setup_totp", "code": ""}'

Expected:

{ "success": true, "secret": "ABCDE...", "message": "Scan the QR code with your authenticator app" }

Check your database: users.totp_secret should have a 32-character string. totp_enabled should still be empty.

Test 2 - Confirm with wrong code

curl -X POST https://your-domain.com/api/v1/YOUR_ID/totp \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "action": "confirm_totp", "code": "000000"}'

Expected: { "success": false, "error": "Invalid code. Make sure you scanned the correct QR code." }

Test 3 - Confirm with correct code

Open Google Authenticator, read the current 6-digit code, and send it:

curl -X POST https://your-domain.com/api/v1/YOUR_ID/totp \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "action": "confirm_totp", "code": "THE_CODE"}'

Expected: { "success": true, "message": "TOTP enabled successfully" }

Check your database: users.totp_enabled should now be "yes".

Test 4 - Verify TOTP (simulates login)

curl -X POST https://your-domain.com/api/v1/YOUR_ID/totp \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "action": "verify_totp", "code": "CURRENT_CODE"}'

Expected: { "success": true, "message": "TOTP verified", "name": "..." }

Test 5 - Disable TOTP

curl -X POST https://your-domain.com/api/v1/YOUR_ID/totp \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "action": "disable_totp", "code": ""}'

Expected: { "success": true, "message": "TOTP has been disabled" }

Check your database: both totp_secret and totp_enabled should be empty.

Edge cases

Test Expected
Setup when already enabled "TOTP is already enabled. Disable it first."
Confirm without running setup first "No TOTP setup in progress. Run setup_totp first."
Verify when TOTP is not enabled "TOTP is not enabled"
Disable when TOTP is not enabled "TOTP is not enabled"
Any action with non-existent email "User not found"
Confirm/verify with wrong code Error message, no database changes

Security Checklist

Control Status
Base32 secret (32 characters)
30-second time window with ±1 drift tolerance
Two-step setup (setup then confirm)
Secret never re-exposed after setup
Clean disable (clears secret and flag)
Action validation (whitelist of allowed actions)
User existence check before any action
State checks (TOTP enabled/disabled before action)
Pure JS TOTP verification (no external dependencies)
HMAC-SHA1 + RFC 6238 compliant
No real secrets in tutorial JSON