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_secretexists in your Vault (Settings → Vault). The Code node reads the value fromvariables.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 |
|---|---|
{{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:
- Query Data nodes (Steps 4a, 4c): Select your actual datasources and columns in each node. The JSON uses placeholder IDs.
- Write Data nodes (Steps 8a, 8b): Select your
login_attemptsdatasource and map the columns. - Vault: Ensure
my_jwt_secretexists in Settings → Vault. The Code node accesses it viavariables.secrets.my_jwt_secret. - Flow Start: Update CORS origins if your frontend is on a different domain.
- 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 | ✅ |