Boundary Framework includes production-ready Multi-Factor Authentication (MFA) support using TOTP (Time-based One-Time Password) authentication. This guide covers setup, usage, and security considerations.
TOTP Authentication: Compatible with all major authenticator apps:
Backup Codes:
Easy Setup:
Seamless Integration:
one-time/one-time (Clojure TOTP library)# 1. Setup MFA (get QR code and backup codes)
curl -X POST http://localhost:3000/api/auth/mfa/setup \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
| jq '.'
# Save the response - you'll need:
# - secret (for manual entry)
# - qr-code-url (scan with authenticator app)
# - backup-codes (save these securely!)
# 2. Scan QR code with your authenticator app
# Or manually enter the secret
# 3. Get current 6-digit code from your app
# 4. Enable MFA with verification
curl -X POST http://localhost:3000/api/auth/mfa/enable \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"secret": "JBSWY3DPEHPK3PXP",
"backupCodes": ["3LTW-XRM1-GYVF", "CN2K-1AWR-GDVT", ...],
"verificationCode": "123456"
}' \
| jq '.'
# 5. Test login with MFA
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "your-password",
"mfa-code": "123456"
}' \
| jq '.'
┌─────────────────────────────────────────────────────────┐
│ User: Authenticated │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ POST /api/auth/mfa/setup │
│ → Returns: secret, QR code URL, 10 backup codes │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ User: Scans QR code with authenticator app │
│ → App displays 6-digit code (changes every 30s) │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ User: Saves backup codes securely │
│ → Print, password manager, or secure storage │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ POST /api/auth/mfa/enable │
│ → Sends: secret, backup codes, verification code │
│ → Server validates TOTP code │
│ → Enables MFA if valid │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ MFA Enabled: All future logins require MFA code │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ POST /api/auth/login (email + password only) │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Response: {"requires-mfa?": true} │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ User: Gets 6-digit code from authenticator app │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ POST /api/auth/login (email + password + mfa-code) │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Success: {"success": true, "session-id": "...", ...} │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ User: Lost access to authenticator app │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ POST /api/auth/login │
│ → Uses backup code instead of TOTP code │
│ → "mfa-code": "3LTW-XRM1-GYVF" │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Backup code validated and marked as used │
│ → Code cannot be reused │
│ → 9 backup codes remaining │
└─────────────────────────────────────────────────────────┘
Endpoint: POST /api/auth/mfa/setup
Authentication: Required (Bearer token)
Request: No body required
Response:
{
"success?": true,
"secret": "JBSWY3DPEHPK3PXP",
"qr-code-url": "https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=otpauth%3A%2F%2Ftotp%2FBoundary%2520Framework%3Auser%40example.com%3Fsecret%3DJBSWY3DPEHPK3PXP%26issuer%3DBoundary%2520Framework",
"backup-codes": [
"3LTW-XRM1-GYVF",
"CN2K-1AWR-GDVT",
"9FHJ-K2LM-PQRS",
"7TUV-W3XY-Z4AB",
"5CDE-F6GH-I8JK",
"2LMN-O9PQ-R1ST",
"8UVW-X4YZ-A5BC",
"6DEF-G7HI-J9KL",
"4MNO-P2QR-S3TU",
"1VWX-Y8ZA-B0CD"
],
"issuer": "Boundary Framework",
"account-name": "user@example.com"
}
Usage:
curl -X POST http://localhost:3000/api/auth/mfa/setup \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json"
Notes:
Endpoint: POST /api/auth/mfa/enable
Authentication: Required (Bearer token)
Request:
{
"secret": "JBSWY3DPEHPK3PXP",
"backupCodes": [
"3LTW-XRM1-GYVF",
"CN2K-1AWR-GDVT",
...
],
"verificationCode": "123456"
}
Response (Success):
{
"success?": true
}
Response (Invalid Code):
{
"success?": false,
"error": "Invalid verification code"
}
Usage:
curl -X POST http://localhost:3000/api/auth/mfa/enable \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"secret": "JBSWY3DPEHPK3PXP",
"backupCodes": ["3LTW-XRM1-GYVF", ...],
"verificationCode": "123456"
}'
Notes:
Endpoint: POST /api/auth/mfa/disable
Authentication: Required (Bearer token)
Request: No body required
Response:
{
"success?": true
}
Usage:
curl -X POST http://localhost:3000/api/auth/mfa/disable \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json"
Notes:
Endpoint: GET /api/auth/mfa/status
Authentication: Required (Bearer token)
Request: No body required
Response (MFA Enabled):
{
"enabled": true,
"enabled-at": "2024-01-04T10:00:00Z",
"backup-codes-remaining": 10
}
Response (MFA Disabled):
{
"enabled": false,
"enabled-at": null,
"backup-codes-remaining": 0
}
Usage:
curl -X GET http://localhost:3000/api/auth/mfa/status \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
Notes:
Endpoint: POST /api/auth/login
Authentication: None (this endpoint creates session)
Request (Password Only - First Attempt):
{
"email": "user@example.com",
"password": "your-password"
}
Response (MFA Required):
{
"requires-mfa?": true,
"message": "MFA code required"
}
Request (With MFA Code - Second Attempt):
{
"email": "user@example.com",
"password": "your-password",
"mfa-code": "123456"
}
Response (Success):
{
"success": true,
"session-id": "550e8400-e29b-41d4-a716-446655440000",
"user": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"email": "user@example.com",
"name": "John Doe"
}
}
Usage (Two-Step Process):
# Step 1: Try login with password only
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "your-password"
}'
# Response: {"requires-mfa?": true}
# Step 2: Login with MFA code
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "your-password",
"mfa-code": "123456"
}'
Using Backup Code:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "your-password",
"mfa-code": "3LTW-XRM1-GYVF"
}'
Notes:
mfa-code fieldjava.security.SecureRandom (cryptographically secure)one-time library (typically ±1 window)Protects Against:
Does NOT Protect Against:
Symptoms: Setup completes successfully, but enable fails with invalid code
Causes:
Solutions:
# Check server time
date -u
# Ensure NTP is running
sudo systemctl status ntp # Linux
sudo systemctl status systemsetup # macOS
# Try fresh code from authenticator app
# Wait for code to refresh before submitting
Symptoms: Backup code rejected during login
Causes:
Solutions:
# Check MFA status to see remaining codes
curl -X GET http://localhost:3000/api/auth/mfa/status \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
# Ensure format is correct: XXXX-XXXX-XXXX
# Try different backup code
Symptoms: Authenticator app won't recognize QR code
Causes:
Solutions:
# Use manual entry instead
# Copy the "secret" field from setup response
# Enter manually in authenticator app
# Or regenerate setup
curl -X POST http://localhost:3000/api/auth/mfa/setup \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
Symptoms: Can't log in, don't have TOTP codes
Solutions:
# Check MFA status
curl -X GET http://localhost:3000/api/auth/mfa/status \
-H "Authorization: Bearer YOUR_JWT_TOKEN" | jq '.'
# Test TOTP code validation (REPL)
clojure -M:repl-clj <<EOF
(require '[boundary.user.shell.mfa :as mfa])
(mfa/verify-totp-code "123456" "JBSWY3DPEHPK3PXP")
EOF
# Check database for MFA settings
psql -U boundary_dev -d boundary_dev \
-c "SELECT id, email, mfa_enabled, mfa_enabled_at FROM users WHERE email='user@example.com';"
MFA implementation follows Boundary's FC/IS architecture:
src/boundary/user/core/mfa.clj)Pure functions, no side effects:
;; Business logic decisions
(should-require-mfa? user risk-analysis)
(can-enable-mfa? user)
(can-disable-mfa? user)
;; Data transformations
(prepare-mfa-enablement user secret codes time)
(prepare-mfa-disablement user time)
;; Validation logic
(is-valid-backup-code? user code)
(mark-backup-code-used user code)
src/boundary/user/shell/mfa.clj)All I/O operations:
;; Crypto operations
(generate-totp-secret) ; SecureRandom
(verify-totp-code code secret) ; TOTP verification
(generate-backup-codes count) ; Random generation
;; External services
(create-qr-code-url uri) ; QR code service
;; Database operations (via repository)
(setup-mfa service user-id)
(enable-mfa service user-id secret codes code)
(disable-mfa service user-id)
src/boundary/user/
├── core/
│ └── mfa.clj # Pure business logic (350 lines)
├── shell/
│ ├── mfa.clj # I/O operations (270 lines)
│ ├── auth.clj # Authentication integration
│ ├── http.clj # HTTP endpoints
│ └── module_wiring.clj # Integrant wiring
├── schema.clj # Malli validation schemas
└── ports.clj # Protocol definitions
migrations/
└── 006_add_mfa_to_users.sql # Database schema
test/boundary/user/
├── core/
│ └── mfa_test.clj # Pure function tests (9 tests)
└── shell/
└── mfa_test.clj # I/O operation tests (12 tests)
┌─────────────────────────────────────────────────────────┐
│ HTTP Layer (shell/http.clj) │
│ → POST /api/auth/mfa/* │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ MFA Service (shell/mfa.clj) │
│ → setup-mfa, enable-mfa, disable-mfa │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Core Logic (core/mfa.clj) │
│ → Business rules, validation, decisions │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Repository (user-repository) │
│ → Database persistence │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Database (PostgreSQL/SQLite) │
│ → users table with MFA columns │
└─────────────────────────────────────────────────────────┘
-- Migration: 006_add_mfa_to_users.sql
ALTER TABLE users ADD COLUMN mfa_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE users ADD COLUMN mfa_secret TEXT;
ALTER TABLE users ADD COLUMN mfa_backup_codes TEXT; -- JSON array
ALTER TABLE users ADD COLUMN mfa_backup_codes_used TEXT; -- JSON array
ALTER TABLE users ADD COLUMN mfa_enabled_at TIMESTAMP;
CREATE INDEX idx_users_mfa_enabled ON users(mfa_enabled);
CREATE INDEX idx_users_mfa_enabled_at ON users(mfa_enabled_at);
Unit Tests (core/mfa_test.clj):
Integration Tests (shell/mfa_test.clj):
Total Coverage: 21 tests, 117 assertions, 0 failures
For issues, questions, or feature requests:
Last Updated: 2026-01-04
Version: 1.0.0
Status: Production Ready
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |