IntermediateAuthentication

User Login

Authenticate users, verify account status, check OTP/TOTP, and issue JWT tokens.

Click to expand
Flow StartAPI POST /loginData MapperdataMapperData ValidatordataValidatorTRUEFALSESimple Output400 Invalid inputQuery Datauser_resultsQuery Datajwt_secretQuery Datalockout_dataCodeauth_resultConditionIFELSESimple OutputNo writeConditionIFELSEWrite DataUPSERT login_attemptsSimple OutputFailure responseWrite DataUPDATE login_attemptsSimple OutputSuccess response

User Login

Authenticate users with email and password, validate input, enforce account lockout, verify account status, check OTP/TOTP, and issue secure JWT tokens.

What You'll Build

A production-ready login endpoint that:

  • Validates email and password format using Data Mapper + Data Validator
  • Normalizes email to prevent case-sensitivity issues
  • Queries the user from the database
  • Retrieves the JWT signing secret from the Vault
  • Checks account lockout (brute-force protection)
  • Verifies the password with bcrypt
  • Checks if the email is verified
  • Checks if TOTP (authenticator app) is enabled
  • Checks if OTP (email/SMS) is enabled
  • Issues a JWT token with proper claims (exp, iat, sub, iss)
  • Records failed login attempts and resets on success

Endpoint: POST /api/v1/YOUR_ID/login

Request body:

{
  "email": "user@example.com",
  "password": "MyP@ssw0rd!"
}

Possible responses:

Scenario Response
Success (no OTP/TOTP) { "success": true, "token": "jwt...", "email": "...", "name": "...", "otp_enabled": "no", "totp_enabled": "no" }
TOTP required { "success": false, "mobile_auth_required": true, "message": "Authenticator verification required" }
OTP required { "success": false, "otp_required": true, "message": "OTP verification required" }
Invalid credentials { "success": false, "error": "Invalid credentials" }
Account locked { "success": false, "error": "Account temporarily locked. Try again in a few minutes." }
Email not verified { "success": false, "error": "Please verify your email before logging in." }
Invalid input 400 status with validation error from Data Validator

Database Setup

Table: users

Same table used by the registration tutorial. Make sure it includes:

Column Type Description
email TEXT User's email (unique)
password TEXT Bcrypt hash
name TEXT Display name
verified TEXT "yes" or "no"
otp_enabled TEXT "yes" or empty
totp_enabled TEXT "yes" or empty

Table: login_attempts

A table for brute-force protection:

Column Type Description
email TEXT The email that was attempted
attempts TEXT Number of consecutive failed attempts (as string)
locked_until TEXT Millisecond timestamp when lockout expires (empty = not locked)
last_attempt TEXT Millisecond timestamp of last failed attempt

Vault secret

Store your JWT signing key in the Vault (Settings → Vault) with the key name my_jwt_secret. In node fields, reference it using {{secrets.my_jwt_secret}}. In Code Execution nodes, access it via variables.secrets.my_jwt_secret.

Workflow Flow

Flow Start (API POST /login)
  ↓
Data Mapper                     → dataMapper
  ↓
Data Validator                  → dataValidator
  TRUE ↓                          FALSE → Simple Output (400)
  ├→ Query Data (users)         → user_results     (parallel)
  ├→ Query Data (jwt_secret)    → jwt_secret        (parallel)
  └→ Query Data (login_attempts)→ lockout_data      (parallel)
       ↓ (all three feed into)
     Code Node                  → auth_result
       ↓
     Condition (needsWrite?)
       ELSE → Simple Output (200)
       IF → Condition (writeType?)
         IF (record_failure) →
           Write Data (UPSERT login_attempts) → Simple Output (200)
         ELSE (reset_attempts) →
           Write Data (UPDATE login_attempts) → Simple Output (200)

Node Quick Reference

Step Node Type Variable Name What it does
1 Flow Start API POST /login, 30 req/min
2 Data Mapper dataMapper Map email + password fields
3 Data Validator dataValidator Validate email format + password present
3F Simple Output 400 error on validation failure
4a Query Data user_results Fetch user by email (parallel)
4b Query Data jwt_secret Fetch JWT signing key (parallel)
4c Query Data lockout_data Fetch login attempts for email (parallel)
5 Code auth_result All auth logic (lockout, bcrypt, JWT)
6 Condition needsWrite? → IF: write / ELSE: output
7 Condition writeType? → route to correct Write Data
8a Write Data write_failure UPSERT login_attempts (failed login)
8b Write Data write_reset UPDATE login_attempts (successful login)

Step 1 - Flow Start

Create a new workflow with an API trigger.

Setting Value
Trigger Type API
Method POST
Custom Path login
Rate Limit 30 req/min
Timeout 30
CORS Origins Add your frontend domain

The frontend sends email and password as a flat JSON body. The rate limit of 30 per minute helps prevent brute-force attacks at the API level.

Step 2 - Data Mapper

Map the incoming request fields so downstream nodes can reference them cleanly.

Output variable: dataMapper

Field Mapping
email email
password password

The Data Mapper produces a clean object { "email": "...", "password": "..." } that the Data Validator and Code node will reference via {{dataMapper.email}} and {{dataMapper.password}}.

Step 3 - Data Validator

Validate the mapped input before running any database queries.

Output variable: dataValidator

Field Type Required Validation
email string yes minLength: 5, pattern: ^[^\s@]+@[^\s@]+\.[^\s@]+$
password string yes minLength: 1

The Data Validator has two output ports:

Port Fires when Next node
TRUE All validations pass Three parallel Query Data nodes
FALSE Any validation fails Simple Output (400 error)

Step 3F - Simple Output (Validation Error)

Connected to the Data Validator's FALSE port.

Setting Value
Status 400
Type JSON
Output {{dataValidator}}

This returns the validator's error details directly to the caller.

Step 4a - Query Data: user_results

Connected to the Data Validator's TRUE port. Runs in parallel with Steps 4b and 4c.

Output variable: user_results

Setting Value
Source users (Structured)
Filter email equals {{dataMapper.email}}
Limit 1

Returns the user row matching the email, or empty rows if not found.

Step 4b - Query Data: jwt_secret

Also connected to the Data Validator's TRUE port (parallel).

Output variable: jwt_secret

The JWT signing secret is now stored in the Vault. In node fields, reference it using {{secrets.my_jwt_secret}}. In Code Execution nodes, access it via variables.secrets.my_jwt_secret.

After importing, make sure the secret my_jwt_secret exists in your Vault (Settings → Vault). The Code node reads the value from variables.secrets.my_jwt_secret.

Step 4c - Query Data: lockout_data

Also connected to the Data Validator's TRUE port (parallel).

Output variable: lockout_data

Setting Value
Source login_attempts (Structured)
Filter email equals {{dataMapper.email}}
Limit 1

Returns the lockout record for this email (attempts count, locked_until timestamp).

Step 5 - Code Node: auth_result

All three Query Data outputs feed into this single Code node. This is where all the authentication logic lives.

Output variable: auth_result

(function() {
  // ── Gather inputs ──────────────────────────────────────────
  // Wrap {{var}} in array so empty values become undefined instead of syntax errors
  var mapper = [{{dataMapper}}][0] || {};
  var email = (mapper.email || '').toLowerCase().trim();
  var password = mapper.password || '';

  var userQuery = [{{user_results}}][0] || {};
  var userRows = userQuery.rows || [];
  var user = userRows.length > 0 ? userRows[0] : null;

  var secretQuery = [{{jwt_secret}}][0] || {};
  var secret = variables.secrets.my_jwt_secret || null;

  var lockoutQuery = [{{lockout_data}}][0] || {};
  var lockoutRows = lockoutQuery.rows || [];
  var lockout = lockoutRows.length > 0 ? lockoutRows[0] : null;

  var now = Date.now();

  // ── Helper: build failure response ─────────────────────────
  function fail(error, needsWrite, writeType) {
    var currentAttempts = lockout ? parseInt(lockout.attempts || '0', 10) : 0;
    var newAttempts = currentAttempts + 1;
    return {
      statusCode: 200,
      success: false,
      error: error,
      needsWrite: needsWrite || false,
      writeType: writeType || '',
      email: email,
      attempts: newAttempts.toString(),
      locked_until: newAttempts >= 5 ? (now + 900000).toString() : '',
      last_attempt: now.toString(),
      now: now.toString()
    };
  }

  // ── 1. Account lockout check ───────────────────────────────
  if (lockout && lockout.locked_until) {
    var lockedUntil = parseInt(lockout.locked_until, 10);
    if (now < lockedUntil) {
      return fail('Account temporarily locked. Try again in a few minutes.', false, '');
    }
  }

  // ── 2. User exists? ────────────────────────────────────────
  if (!user) {
    return fail('Invalid credentials', true, 'record_failure');
  }

  // ── 3. Password verification ───────────────────────────────
  var passwordValid = bcryptVerify(password, user.password);
  if (!passwordValid) {
    return fail('Invalid credentials', true, 'record_failure');
  }

  // ── 4. Email verified? ─────────────────────────────────────
  if (user.verified !== 'yes') {
    return fail('Please verify your email before logging in.', false, '');
  }

  // ── 5. TOTP check (authenticator app) ──────────────────────
  if (user.totp_enabled === 'yes') {
    return {
      statusCode: 200,
      success: false,
      mobile_auth_required: true,
      message: 'Authenticator verification required',
      email: email,
      needsWrite: true,
      writeType: 'reset_attempts'
    };
  }

  // ── 6. OTP check (email/SMS) ───────────────────────────────
  if (user.otp_enabled === 'yes') {
    return {
      statusCode: 200,
      success: false,
      otp_required: true,
      message: 'OTP verification required',
      email: email,
      needsWrite: true,
      writeType: 'reset_attempts'
    };
  }

  // ── 7. Issue JWT ───────────────────────────────────────────
  if (!secret) {
    return fail('Server configuration error', false, '');
  }

  var iat = Math.floor(now / 1000);
  var exp = iat + 3600; // 1 hour

  var token = jwtSign({
    sub: email,
    iss: 'ubex',
    iat: iat,
    exp: exp
  }, secret);

  return {
    statusCode: 200,
    success: true,
    token: token,
    email: email,
    name: user.name || '',
    otp_enabled: user.otp_enabled || 'no',
    totp_enabled: user.totp_enabled || 'no',
    needsWrite: true,
    writeType: 'reset_attempts'
  };
})();

Why [{{variable}}][0] || {}?

When a Query Data node sits on a path that hasn't executed yet (or returns nothing), {{variable}} resolves to an empty string at runtime. Writing var x = ; is a syntax error. Wrapping it in an array literal - [{{variable}}][0] - turns an empty string into [][0] which is undefined, and || {} gives you a safe empty object.

Why does this Code node take ~87ms?

bcryptVerify is intentionally slow. With the default cost factor of 10 (1024 hashing rounds), it takes roughly 80-100ms. This is by design - it makes brute-force attacks computationally expensive. All other nodes run in under 1ms.

Step 6 - Condition: needsWrite?

Check whether the Code node flagged that a database write is needed.

Branch Condition Next
IF {{auth_result.needsWrite}} equals true Step 7 (writeType?)
ELSE No write needed Simple Output (200)

Step 6 ELSE - Simple Output (No Write)

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

This handles cases like account lockout or email not verified where no database update is needed.

Step 7 - Condition: writeType?

Route to the correct Write Data node based on the failure/success type.

Branch Condition Next
IF {{auth_result.writeType}} equals record_failure Write Data (UPSERT)
ELSE reset_attempts (success, OTP, TOTP) Write Data (UPDATE)

Step 8a - Write Data: UPSERT login_attempts (Failed Login)

Record the failed attempt. Uses UPSERT so it inserts a new row if none exists, or updates the existing one.

Output variable: write_failure

Setting Value
Source login_attempts
Operation UPSERT
Match Column email
Column Value
email {{auth_result.email}}
attempts {{auth_result.attempts}}
locked_until {{auth_result.locked_until}}
last_attempt {{auth_result.last_attempt}}

After 5 failed attempts, locked_until is set to now + 900000 (15 minutes). Below 5, it stays empty.

Step 8a Output - Simple Output

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

Step 8b - Write Data: UPDATE login_attempts (Reset on Success)

On successful login (or OTP/TOTP redirect), reset the attempt counter.

Output variable: write_reset

Setting Value
Source login_attempts
Operation UPDATE
Where email equals {{auth_result.email}}
Column Value
attempts 0
locked_until (empty)
last_attempt (empty)

Step 8b Output - Simple Output

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

Post-Import Setup

After importing the workflow JSON, you need to configure a few things that are user-specific:

  1. Query Data nodes (Steps 4a, 4c): Select your actual datasources and columns in each node. The JSON uses placeholder IDs.
  2. Write Data nodes (Steps 8a, 8b): Select your login_attempts datasource and map the columns.
  3. Vault: Ensure my_jwt_secret exists in Settings → Vault. The Code node accesses it via variables.secrets.my_jwt_secret.
  4. Flow Start: Update CORS origins if your frontend is on a different domain.
  5. Test with Manual Run before deploying to production.

Testing

Using curl

Test 1 - Successful login:

curl -X POST https://workflow.ubex.ai/api/v1/YOUR_ID/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "MyP@ssw0rd!"}'

Expected:

{
  "success": true,
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "email": "user@example.com",
  "name": "User Name",
  "otp_enabled": "no",
  "totp_enabled": "no"
}

Test 2 - Invalid credentials:

curl -X POST https://workflow.ubex.ai/api/v1/YOUR_ID/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "wrongpassword"}'

Expected: { "success": false, "error": "Invalid credentials" }

Test 3 - Invalid email format:

curl -X POST https://workflow.ubex.ai/api/v1/YOUR_ID/login \
  -H "Content-Type: application/json" \
  -d '{"email": "not-an-email", "password": "test"}'

Expected: 400 status with Data Validator error.

Test 4 - Missing fields:

curl -X POST https://workflow.ubex.ai/api/v1/YOUR_ID/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'

Expected: 400 status with Data Validator error.

Test 5 - Account lockout (run 5 failed attempts):

After 5 consecutive wrong passwords:

Expected: { "success": false, "error": "Account temporarily locked. Try again in a few minutes." }

Security Features

Feature Implementation
Rate limiting 30 requests/minute at API level
Brute-force protection 5 failed attempts → 15 minute lockout
Password hashing bcrypt with cost factor 10 (~87ms verify)
JWT expiry 1 hour (exp claim)
JWT claims sub (email), iss ("ubex"), iat, exp
Input validation Data Validator checks email format + password presence
Email normalization toLowerCase().trim() in Code node
Generic error messages "Invalid credentials" for both wrong email and wrong password

Security Checklist

Control Status
POST only (no GET)
Schema validation (Data Validator)
Email format validation (regex pattern)
Email normalization (lowercase, trim)
Password verified with bcrypt
Account lockout after 5 failed attempts (15 min)
JWT with expiry (1 hour)
JWT claims (sub, iss, iat, exp)
Generic error messages (no user enumeration)
Failed attempt tracking (login_attempts table)
Attempts reset on successful login
Rate limiting (30/min)
TOTP check before JWT issuance
OTP check before JWT issuance
CORS configuration
No real secrets in tutorial JSON