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
codefield is only needed forconfirm_totpandverify_totp. Forsetup_totpanddisable_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_enabledalongside the TOTP columns - theverify_totpaction returns this value so the frontend knows whether email OTP is also active. Make suretotp_secretandtotp_enabledare 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 |
|---|---|---|
| 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_enabledstays empty - confirm_totp: Keeps the secret, sets
totp_enabledto"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:
- Open the Query Data node → select your
userstable → add columns:email,name,otp_enabled,totp_secret,totp_enabled→ set the filter column toemail - Open the Write Data node → select your
userstable → set the WHERE filter column toemail→ map the columns:totp_secretandtotp_enabled - 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_enabledandtotp_secretto the columns in your login workflow's Query Data node.
How the full login flow works
- User enters email + password → clicks Sign in
- Login workflow checks password → sees
totp_enabled = "yes"→ returns{ mobile_auth_required: true } - Frontend shows "Enter authenticator code" input
- User opens Google Authenticator → reads the current 6-digit code → enters it
- Frontend calls
/totpwith{ action: "verify_totp", email, code } - If code is correct → user is logged in
How the setup flow works
- User is logged in → goes to Security settings → clicks the TOTP toggle
- Frontend calls
/totpwith{ action: "setup_totp", email }→ gets back asecret - Frontend builds this URL:
otpauth://totp/Ubex:user@email.com?secret=THE_SECRET&issuer=Ubex&digits=6&period=30 - Frontend renders that URL as a QR code (using any QR library)
- User scans the QR with Google Authenticator → the app adds "Ubex" to its list
- User enters the 6-digit code shown in the app
- Frontend calls
/totpwith{ 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:
- Password check passes
- If
totp_enabled === "yes"→ returnmobile_auth_required: true(TOTP takes priority) - If
otp_enabled === "yes"→ returnotp_required: true - 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 | ✅ |