Flink plugin for OTP (One-Time Password) authentication via SMS or email
npm install @flink-app/otp-auth-pluginA flexible OTP (One-Time Password) authentication plugin for Flink that supports both SMS and email delivery methods with MongoDB session storage, JWT token generation, and configurable security settings.
- OTP code generation with configurable length (4-8 digits)
- Support for both SMS and email delivery
- Configurable code expiration (TTL)
- Rate limiting with maximum verification attempts
- Session locking after too many failed attempts
- MongoDB session storage with automatic TTL cleanup
- Built-in HTTP endpoints for OTP flow
- TypeScript support with full type safety
- Cryptographically secure code generation
- Identifier validation and normalization
``bash`
npm install @flink-app/otp-auth-plugin @flink-app/jwt-auth-plugin
This plugin requires @flink-app/jwt-auth-plugin to be installed and configured. The OTP Auth Plugin uses the JWT Auth Plugin to generate authentication tokens after successful verification.
You need an SMS or email delivery mechanism. This can be another Flink plugin or external service:
- SMS: Use @flink-app/sms-plugin or services like Twilio, AWS SNS@flink-app/email-plugin
- Email: Use or services like SendGrid, AWS SES
The plugin requires MongoDB to store OTP sessions.
`typescript
import { FlinkApp } from "@flink-app/flink";
import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
import { otpAuthPlugin } from "@flink-app/otp-auth-plugin";
import { smsPlugin } from "@flink-app/sms-plugin";
import { emailPlugin } from "@flink-app/email-plugin";
import { Context } from "./Context";
const app = new FlinkApp
name: "My App",
// JWT Auth Plugin MUST be configured first
auth: jwtAuthPlugin({
secret: process.env.JWT_SECRET!,
getUser: async (tokenData) => {
return await app.ctx.repos.userRepo.getById(tokenData.userId);
},
rolePermissions: {
user: ["read", "write"],
admin: ["read", "write", "delete"],
},
}),
db: {
uri: process.env.MONGODB_URI!,
},
plugins: [
// SMS and email plugins for delivery
smsPlugin({
provider: "twilio",
accountSid: process.env.TWILIO_ACCOUNT_SID!,
authToken: process.env.TWILIO_AUTH_TOKEN!,
fromNumber: process.env.TWILIO_FROM_NUMBER!,
}),
emailPlugin({
provider: "sendgrid",
apiKey: process.env.SENDGRID_API_KEY!,
fromEmail: "noreply@myapp.com",
}),
// OTP Auth Plugin
otpAuthPlugin({
codeLength: 6, // 6-digit code
codeTTL: 300, // 5 minutes
maxAttempts: 3, // Lock after 3 failed attempts
// Callback to send the OTP code
onSendCode: async (code, identifier, method) => {
if (method === "sms") {
await app.ctx.plugins.sms.send({
to: identifier,
body: Your verification code is: ${code}. Valid for 5 minutes.,Your verification code is: ${code}. Valid for 5 minutes.
});
} else {
await app.ctx.plugins.email.send({
to: identifier,
subject: "Your Verification Code",
text: ,
html:
Your verification code is: ${code}
Valid for 5 minutes.
,
});
}
return true;
}, // Callback to retrieve user by identifier
onGetUser: async (identifier, method) => {
if (method === "sms") {
return await app.ctx.repos.userRepo.findOne({ phoneNumber: identifier });
} else {
return await app.ctx.repos.userRepo.findOne({ email: identifier });
}
},
// Callback after successful verification
onVerifySuccess: async (user, identifier, method) => {
const token = await app.ctx.auth.createToken({ userId: user._id, email: user.email }, user.roles);
return {
user: {
id: user._id,
email: user.email,
phoneNumber: user.phoneNumber,
name: user.name,
},
token,
};
},
}),
],
});
await app.start();
`Configuration
$3
| Option | Type | Required | Default | Description |
| --------------------------- | ---------- | -------- | ---------------- | --------------------------------------------- |
|
codeLength | number | No | 6 | Number of digits in OTP code (4-8) |
| codeTTL | number | No | 300 | Code validity in seconds (default: 5 minutes) |
| maxAttempts | number | No | 3 | Max verification attempts before lock |
| onSendCode | Function | Yes | - | Callback to send OTP code |
| onGetUser | Function | Yes | - | Callback to retrieve user by identifier |
| onVerifySuccess | Function | Yes | - | Callback after successful verification |
| keepSessionsSec | number | No | 86400 | Session retention in seconds (24 hours) |
| otpSessionsCollectionName | string | No | "otp_sessions" | MongoDB collection name |
| registerRoutes | boolean | No | true | Register built-in HTTP endpoints |$3
#### onSendCode
Send the OTP code to the user via SMS or email.
`typescript
onSendCode: async (
code: string,
identifier: string,
method: "sms" | "email",
payload?: Record
) => Promise
`Parameters:
-
code - The generated OTP code (e.g., "123456")
- identifier - User identifier (phone number or email)
- method - Delivery method ("sms" or "email")
- payload - Optional custom data from initiationReturns:
true if sent successfully, false otherwiseExample:
`typescript
onSendCode: async (code, identifier, method, payload) => {
if (method === "sms") {
await ctx.plugins.sms.send({
to: identifier,
body: Your code: ${code},
});
} else {
await ctx.plugins.email.send({
to: identifier,
subject: "Your verification code",
text: Your code is: ${code},
});
}
return true;
};
`#### onGetUser
Retrieve user by their identifier (phone number or email).
`typescript
onGetUser: async (
identifier: string,
method: "sms" | "email",
payload?: Record
) => Promise
`Parameters:
-
identifier - User identifier (normalized phone/email)
- method - Delivery method ("sms" or "email")
- payload - Optional custom data from initiationReturns: User object or
null if not foundExample:
`typescript
onGetUser: async (identifier, method) => {
if (method === "sms") {
return await ctx.repos.userRepo.findOne({ phoneNumber: identifier });
} else {
return await ctx.repos.userRepo.findOne({ email: identifier });
}
};
`#### onVerifySuccess
Generate JWT token and return user data after successful verification.
`typescript
onVerifySuccess: async (
user: any,
identifier: string,
method: "sms" | "email",
payload?: Record
) => Promise<{
user: any;
token: string;
}>
`Parameters:
-
user - User object from onGetUser
- identifier - User identifier
- method - Delivery method
- payload - Optional custom data from initiationReturns: Object with user and JWT token
Example:
`typescript
onVerifySuccess: async (user, identifier, method, payload) => {
// Generate JWT token
const token = await ctx.auth.createToken({ userId: user._id, email: user.email }, user.roles); // Update last login time
await ctx.repos.userRepo.updateOne(user._id, {
lastLoginAt: new Date(),
lastLoginMethod: method,
});
return {
user: {
id: user._id,
email: user.email,
name: user.name,
},
token,
};
};
`OTP Authentication Flow
$3
1. User enters phone number or email
2. Client calls
/otp/initiate with identifier and method
3. Plugin generates OTP code and creates session
4. Plugin calls onSendCode to deliver code
5. User receives code via SMS or email
6. User enters code in client
7. Client calls /otp/verify with session ID and code
8. Plugin validates code and checks attempts/expiration
9. Plugin calls onGetUser to fetch user
10. Plugin calls onVerifySuccess to generate JWT token
11. Plugin returns user and token to client
12. Client stores JWT token for authenticated requests$3
`
POST /otp/initiate
`Request Body:
`json
{
"identifier": "+46701234567",
"method": "sms",
"payload": {
"returnUrl": "/dashboard"
}
}
`Response:
`json
{
"data": {
"sessionId": "a1b2c3d4e5f6...",
"expiresAt": "2025-01-02T12:35:00.000Z",
"ttl": 300
}
}
`$3
`
POST /otp/verify
`Request Body:
`json
{
"sessionId": "a1b2c3d4e5f6...",
"code": "123456"
}
`Response (Success - 200):
`json
{
"data": {
"status": "success",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "507f1f77bcf86cd799439011",
"email": "user@example.com",
"name": "John Doe"
}
}
}
`Response (Invalid Code - 401):
`json
{
"data": {
"status": "invalid_code",
"message": "Invalid verification code",
"remainingAttempts": 2
}
}
`Response (Locked - 403):
`json
{
"data": {
"status": "locked",
"message": "Too many failed attempts. Session is locked.",
"remainingAttempts": 0
}
}
`Response (Expired - 403):
`json
{
"data": {
"status": "expired",
"message": "Verification code has expired"
}
}
`Response (Not Found - 404):
`json
{
"data": {
"status": "not_found",
"message": "Session not found or expired"
}
}
`Context API
The plugin exposes methods via
ctx.plugins.otpAuth:$3
Programmatically initiate OTP authentication.
`typescript
const result = await ctx.plugins.otpAuth.initiate({
identifier: "+46701234567",
method: "sms",
payload: { customData: "value" },
});// Returns: { sessionId, expiresAt, ttl }
`$3
Programmatically verify OTP code.
`typescript
const result = await ctx.plugins.otpAuth.verify({
sessionId: "a1b2c3d4e5f6...",
code: "123456",
});// Returns: { status, token?, user?, remainingAttempts?, message? }
`Identifier Normalization
The plugin automatically normalizes identifiers for consistency:
$3
- Removes spaces, dashes, parentheses:
+46 (70) 123-4567 → +46701234567
- Preserves + prefix for country codes
- Accepts various formats: +1 (555) 123-4567, 555-123-4567, 5551234567$3
- Converts to lowercase:
User@Example.COM → user@example.com
- Trims whitespace
- Basic validation: must contain @ and domainSecurity Best Practices
$3
Balance security and usability:
`typescript
otpAuthPlugin({
codeLength: 6, // 6 digits = 1 million combinations
codeTTL: 300, // 5 minutes - short enough to prevent reuse
maxAttempts: 3, // Lock after 3 tries
});
`Recommendations:
- 4 digits: Only for low-security scenarios (10,000 combinations)
- 6 digits: Good balance for most use cases (1,000,000 combinations)
- 8 digits: High security (100,000,000 combinations)
$3
Implement rate limiting on initiation to prevent SMS/email abuse:
`typescript
import rateLimit from "express-rate-limit";app.use(
"/otp/initiate",
rateLimit({
windowMs: 15 60 1000, // 15 minutes
max: 3, // 3 requests per window
message: "Too many OTP requests. Please try again later.",
})
);
`$3
Always verify user exists before sending codes:
`typescript
onSendCode: async (code, identifier, method) => {
// Check if user exists first
const user = await ctx.repos.userRepo.findOne({
[method === "sms" ? "phoneNumber" : "email"]: identifier,
}); if (!user) {
// Don't reveal user doesn't exist - silently fail
return true;
}
// Send code only if user exists
await sendCode(code, identifier, method);
return true;
};
`$3
Always use HTTPS in production to prevent code interception.
$3
Never commit secrets to version control:
`bash
.env
JWT_SECRET=your_jwt_secret
TWILIO_ACCOUNT_SID=...
TWILIO_AUTH_TOKEN=...
SENDGRID_API_KEY=...
`$3
The plugin automatically cleans up old sessions using MongoDB TTL indexes. Configure retention based on compliance needs:
`typescript
otpAuthPlugin({
keepSessionsSec: 86400, // 24 hours (default)
// keepSessionsSec: 3600, // 1 hour for stricter compliance
// keepSessionsSec: 604800, // 7 days for audit trails
});
`$3
Log authentication attempts for security monitoring:
`typescript
onVerifySuccess: async (user, identifier, method) => {
await ctx.repos.auditLogRepo.create({
userId: user._id,
action: "otp_login",
method,
identifier,
timestamp: new Date(),
ip: req.ip,
}); const token = await ctx.auth.createToken({ userId: user._id }, user.roles);
return { user, token };
};
`Client Integration Examples
$3
`typescript
import React, { useState } from "react";function OtpLogin() {
const [phone, setPhone] = useState("");
const [sessionId, setSessionId] = useState("");
const [code, setCode] = useState("");
const [step, setStep] = useState<"enter-phone" | "enter-code">("enter-phone");
const handleInitiate = async () => {
const response = await fetch("/otp/initiate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identifier: phone,
method: "sms",
}),
});
const { data } = await response.json();
setSessionId(data.sessionId);
setStep("enter-code");
};
const handleVerify = async () => {
const response = await fetch("/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId,
code,
}),
});
const { data } = await response.json();
if (data.status === "success") {
// Store token and redirect
localStorage.setItem("jwt_token", data.token);
window.location.href = "/dashboard";
} else {
alert(data.message || "Verification failed");
}
};
return (
{step === "enter-phone" && (
Login with SMS
setPhone(e.target.value)} placeholder="Phone number" />
)} {step === "enter-code" && (
Enter Verification Code
setCode(e.target.value)} placeholder="6-digit code" maxLength={6} />
)}
);
}
`$3
`typescript
import React, { useState } from "react";
import { View, TextInput, Button, Alert } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";function OtpLogin() {
const [phone, setPhone] = useState("");
const [sessionId, setSessionId] = useState("");
const [code, setCode] = useState("");
const [step, setStep] = useState<"enter-phone" | "enter-code">("enter-phone");
const handleInitiate = async () => {
const response = await fetch("https://api.myapp.com/otp/initiate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identifier: phone,
method: "sms",
}),
});
const { data } = await response.json();
setSessionId(data.sessionId);
setStep("enter-code");
};
const handleVerify = async () => {
const response = await fetch("https://api.myapp.com/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId,
code,
}),
});
const { data } = await response.json();
if (data.status === "success") {
await AsyncStorage.setItem("jwt_token", data.token);
// Navigate to home screen
} else {
Alert.alert("Error", data.message || "Verification failed");
}
};
return (
{step === "enter-phone" && (
<>
>
)}
{step === "enter-code" && (
<>
>
)}
);
}
`Custom Payload Usage
Pass custom data through the OTP flow:
`typescript
// Initiate with payload
const { data } = await fetch("/otp/initiate", {
method: "POST",
body: JSON.stringify({
identifier: "user@example.com",
method: "email",
payload: {
action: "password-reset",
returnUrl: "/new-password",
},
}),
});// Access payload in callbacks
onVerifySuccess: async (user, identifier, method, payload) => {
if (payload?.action === "password-reset") {
// Handle password reset flow
return {
user,
token: await ctx.auth.createToken({ userId: user._id }, ["password-reset"]),
};
}
// Regular login flow
return {
user,
token: await ctx.auth.createToken({ userId: user._id }, user.roles),
};
};
`Error Handling
Handle errors gracefully in your callbacks:
`typescript
onSendCode: async (code, identifier, method) => {
try {
if (method === "sms") {
await ctx.plugins.sms.send({
to: identifier,
body: Your code: ${code},
});
} else {
await ctx.plugins.email.send({
to: identifier,
subject: "Verification code",
text: Your code: ${code},
});
}
return true;
} catch (error) {
// Log error but don't expose details
console.error("Failed to send OTP:", error);
return false; // Return false to indicate failure
}
};
`TypeScript Types
`typescript
import {
OtpAuthPluginOptions,
OtpSession,
InitiateRequest,
InitiateResponse,
VerifyRequest,
VerifyResponse,
OtpAuthPluginContext,
} from "@flink-app/otp-auth-plugin";// OTP Session
interface OtpSession {
_id?: string;
sessionId: string;
identifier: string;
method: "sms" | "email";
code: string;
attempts: number;
maxAttempts: number;
status: "pending" | "verified" | "expired" | "locked";
createdAt: Date;
expiresAt: Date;
verifiedAt?: Date;
payload?: Record;
}
// Initiate request/response
interface InitiateRequest {
identifier: string;
method: "sms" | "email";
payload?: Record;
}
interface InitiateResponse {
sessionId: string;
expiresAt: Date;
ttl: number;
}
// Verify request/response
interface VerifyRequest {
sessionId: string;
code: string;
}
interface VerifyResponse {
status: "success" | "invalid_code" | "expired" | "locked" | "not_found";
token?: string;
user?: any;
remainingAttempts?: number;
message?: string;
}
`Troubleshooting
$3
Issue: User doesn't receive OTP code
Solution:
- Check
onSendCode callback logs for errors
- Verify SMS/email service credentials
- Check identifier format (phone/email validation)
- Ensure user exists in database (if checking in onSendCode)$3
Issue: Verify returns "Session not found"
Solution:
- Session may have expired (check
codeTTL)
- Verify sessionId is passed correctly from initiate response
- Check MongoDB connection and collection name$3
Issue: Session locked after failed verifications
Solution:
- User must request a new code via
/otp/initiate
- Consider implementing admin unlock functionality
- Adjust maxAttempts if too restrictive$3
Issue: Initiate fails with "Invalid phone number/email format"
Solution:
- Ensure phone numbers include country code:
+46701234567
- Validate email format: user@example.com
- Check identifier normalization in logsProduction Checklist
- [ ] Configure HTTPS for all endpoints
- [ ] Set secure JWT secret in environment variables
- [ ] Configure SMS/email service with production credentials
- [ ] Implement rate limiting on
/otp/initiate
- [ ] Set appropriate codeTTL (recommend 5 minutes)
- [ ] Set appropriate maxAttempts (recommend 3)
- [ ] Set appropriate codeLength (recommend 6)
- [ ] Implement audit logging for OTP attempts
- [ ] Set up monitoring for failed verifications
- [ ] Test OTP flow for both SMS and email
- [ ] Configure session retention (keepSessionsSec)
- [ ] Implement user existence check in onSendCode`MIT