MFA Providers
MFA (Multi-Factor Authentication) Providers are pluggable components that handle the delivery and verification of authentication challenges. They allow you to implement various MFA mechanisms without coupling your application logic to specific delivery services.
Overview
The MFAProvider interface defines a standard contract for all MFA providers, supporting multiple delivery channels:
- Email - Send one-time codes via email (SendGrid)
- SMS - Send codes via text message (Twilio)
- TOTP - Time-based One-Time Passwords (Google Authenticator, Authy)
- WebAuthn - FIDO2/Hardware security keys
- Push - Push notifications to mobile apps
- Custom - Your own implementation
- NoOp - No-operation provider for testing
The MFAProvider Interface
type MFAProvider interface {
// SendChallenge sends a one-time token/challenge to the user
SendChallenge(ctx context.Context, params MFAChallengeParams) (*MFADeliveryReceipt, error)
// VerifyChallenge verifies the user's response (optional for token-based MFA)
VerifyChallenge(ctx context.Context, params MFAVerifyParams) error
// StoreChallenge stores the challenge token
StoreChallenge(ctx context.Context, params MFAChallengeStoreParams) error
// ValidateChallenge validates a challenge token
ValidateChallenge(ctx context.Context, token, action, object string) (*MFAChallengeScope, error)
// RetrieveChallenge retrieves a challenge without validation
RetrieveChallenge(ctx context.Context, token string) (*MFAChallengeScope, error)
// DeleteChallenge removes a challenge token
DeleteChallenge(ctx context.Context, token string) error
// Type returns the provider type
Type() MFAProviderType
// String returns a string representation
String() string
}Available Implementations
Email-Based MFA (SendGrid)
Send verification codes via email using SendGrid.
Installation
import "github.com/sendgrid/sendgrid-go"Configuration
provider := providers.NewSendgridMFAProvider(
"SG.xxxxx", // SendGrid API key
"noreply@example.com", // Sender email
"Example App", // Sender name
cacheStore, // CacheStore for token storage
)Template Customization
Override the default template for specific actions:
provider.WithTemplate(
"auth-account-login-opaque",
"Login Verification for {appName}",
"Your login code is: {code}\n\nValid for {expiresIn} minutes.\n\nClick here: https://myapp.com/auth/login/{code}",
)Available Placeholders:
{code}- The one-time verification code{appName}- Application name from metadata{expiresIn}- Expiration duration from metadata- Any key from
MFAChallengeParams.Metadata
Features
- Privacy-friendly (tracking disabled by default)
- Custom templates per action
- Metadata placeholder support
- Delivery receipts with status tracking
SMS-Based MFA (Twilio)
Send verification codes via SMS using Twilio.
Installation
import "github.com/twilio/twilio-go"Configuration
provider := providers.NewTwilioMFAProvider(
"ACxxxxx", // Twilio Account SID
"auth_token", // Twilio Auth Token
"+1234567890", // From phone number (E.164 format)
cacheStore, // CacheStore for token storage
)Template Customization
provider.WithTemplate(
"auth-account-login-opaque",
"Your login code is: {code}. Valid for {expiresIn} minutes.",
)Note: SMS providers only use the message template (no subject line).
Available Placeholders:
{code}- The one-time verification code- Any key from
MFAChallengeParams.Metadata
Template System
How Templates Work
- Action-Based: Templates are registered for specific actions
- Placeholder Replacement:
{key}placeholders are replaced with actual values - Fallback: If no template is registered, a default template is used
- Metadata Integration: All metadata values are available as placeholders
Common Actions
Actions are typically formatted as: domain-component-operation-details
Examples:
auth-account-login-opaque- Opaque login authenticationauth-account-login-emailpassword- Email/password loginauth-account-register-opaque- Opaque registrationauth-account-password-reset- Password reset flow
Template Example
// Register custom template
provider.WithTemplate(
"auth-account-login-opaque",
"Security Code for {appName}",
`Your security code is: {code}
This code will expire in {expiresIn} minutes.
If you didn't request this code, please ignore this message.
IP Address: {ipAddress}`,
)
// When sending, provide metadata
params := comby.MFAChallengeParams{
Recipient: "user@example.com",
ChallengeCode: "123456",
Action: "auth-account-login-opaque",
Metadata: map[string]string{
"appName": "My Application",
"expiresIn": "5",
"ipAddress": "192.168.1.1",
},
}
receipt, err := provider.SendChallenge(ctx, params)Resulting Message:
Subject: Security Code for My Application
Your security code is: 123456
This code will expire in 5 minutes.
If you didn't request this code, please ignore this message.
IP Address: 192.168.1.1Challenge Lifecycle
1. Send Challenge
receipt, err := provider.SendChallenge(ctx, comby.MFAChallengeParams{
Recipient: "user@example.com",
ChallengeCode: "123456",
Action: "auth-account-login-opaque",
Metadata: map[string]string{
"appName": "My App",
"expiresIn": "5",
},
})2. Store Challenge
err := provider.StoreChallenge(ctx, comby.MFAChallengeStoreParams{
OneTimeToken: "123456",
Action: "auth-account-login-opaque",
Object: "user@example.com",
ExpirationDuration: 5 * time.Minute,
})3. Validate Challenge
scope, err := provider.ValidateChallenge(
ctx,
"123456", // Token provided by user
"auth-account-login-opaque", // Expected action
"user@example.com", // Expected object
)4. Delete Challenge
err := provider.DeleteChallenge(ctx, "123456")Implementing Custom MFA Providers
To implement your own MFA provider:
- Implement the Interface: Implement all methods of
MFAProvider - Choose Provider Type: Return appropriate
MFAProviderType - Handle Token Storage: Use
CacheStoreor your own storage mechanism - Support Templates: Allow users to customize messages
- Provide Configuration: Accept necessary credentials and settings
Example Structure
type CustomMFAProvider struct {
config Config
cacheStore comby.CacheStore
templates map[string]comby.MFATemplate
}
func NewCustomMFAProvider(config Config, cache comby.CacheStore) *CustomMFAProvider {
return &CustomMFAProvider{
config: config,
cacheStore: cache,
templates: make(map[string]comby.MFATemplate),
}
}
func (p *CustomMFAProvider) WithTemplate(action, subject, message string) *CustomMFAProvider {
p.templates[action] = comby.MFATemplate{
Subject: subject,
Message: message,
}
return p
}
func (p *CustomMFAProvider) SendChallenge(ctx context.Context, params comby.MFAChallengeParams) (*comby.MFADeliveryReceipt, error) {
// 1. Get template for action
template := p.getTemplate(params.Action)
// 2. Replace placeholders
message := strings.ReplaceAll(template.Message, "{code}", params.ChallengeCode)
for key, value := range params.Metadata {
placeholder := fmt.Sprintf("{%s}", key)
message = strings.ReplaceAll(message, placeholder, value)
}
// 3. Send via your delivery mechanism
deliveryID, err := p.sendViaCustomChannel(params.Recipient, message)
if err != nil {
return nil, err
}
// 4. Return receipt
return &comby.MFADeliveryReceipt{
DeliveryID: deliveryID,
Timestamp: time.Now().Unix(),
Metadata: map[string]string{
"recipient": params.Recipient,
"action": params.Action,
},
}, nil
}
// Implement remaining interface methods...Token Verification Methods
Simple Token-Based (Email/SMS)
For email and SMS-based MFA, verification is straightforward:
func (p *Provider) VerifyChallenge(ctx context.Context, params comby.MFAVerifyParams) error {
// No server-side verification needed
// Token possession proves email/phone access
return nil
}The actual token validation happens through ValidateChallenge, which checks:
- Token exists in cache
- Token hasn't expired
- Action matches
- Object matches
Provider-Side Verification (TOTP/WebAuthn)
For TOTP and WebAuthn, implement actual verification:
func (p *TOTPProvider) VerifyChallenge(ctx context.Context, params comby.MFAVerifyParams) error {
// Verify TOTP code using user's secret
secret, err := p.getUserSecret(ctx, params.UserID)
if err != nil {
return err
}
valid := totp.Validate(params.Code, secret)
if !valid {
return fmt.Errorf("invalid TOTP code")
}
return nil
}Best Practices
Security
- Short Expiration: Keep token expiration short (5-15 minutes)
- Single Use: Delete tokens after successful authentication
- Rate Limiting: Implement throttling on challenge generation
- Secure Storage: Use secure cache stores for token storage
- No Secrets in Logs: Never log challenge codes or sensitive data
Template Design
- Clear Instructions: Make it obvious what the user should do
- Expiration Notice: Always mention when the code expires
- Branding: Include your app name for recognition
- Action Context: Explain why they're receiving the code
- Security Warning: Advise users not to share the code
Error Handling
receipt, err := provider.SendChallenge(ctx, params)
if err != nil {
// Log error details for debugging
logger.Error("failed to send MFA challenge",
"error", err,
"action", params.Action,
"recipient", maskEmail(params.Recipient))
// Return generic error to user
return fmt.Errorf("failed to send verification code")
}Testing
Use NoOp provider during testing:
// In test environment
mfaProvider = &NoOpMFAProvider{
cacheStore: testCacheStore,
}
// In production
mfaProvider = providers.NewSendgridMFAProvider(
os.Getenv("SENDGRID_API_KEY"),
"noreply@example.com",
"My App",
cacheStore,
)Common Metadata Keys
| Key | Description | Example |
|---|---|---|
appName | Application name | "My Application" |
expiresIn | Expiration duration | "5" (minutes) |
linkUrl | Deep link URL | "https://app.com/verify/{code}" |
ipAddress | Request IP address | "192.168.1.1" |
userAgent | User's browser/device | "Mozilla/5.0..." |
timestamp | Request timestamp | "2025-01-15 10:30:00 UTC" |
Troubleshooting
Challenge Not Delivered
Email Providers:
- Verify API credentials are correct
- Check sender email is verified with provider
- Review provider's sending limits
- Check spam/junk folder
SMS Providers:
- Verify phone number format (E.164)
- Check Twilio account balance
- Ensure phone number is verified (sandbox mode)
- Review regional restrictions
Token Validation Fails
scope, err := provider.ValidateChallenge(ctx, token, action, object)
if err != nil {
// Common causes:
// - Token expired (check ExpirationDuration)
// - Action mismatch (ensure consistent action strings)
// - Object mismatch (case-sensitive email comparison)
// - Token not found (already deleted or never stored)
}Template Placeholders Not Replaced
Ensure metadata keys match placeholder names:
// Template: "Code {code} expires in {expiresIn} minutes"
// Metadata must include exact keys:
Metadata: map[string]string{
"expiresIn": "5", // NOT "expires_in" or "expirationTime"
}Related Documentation
- Email Providers - Email delivery providers
- Account Authentication - Using MFA with account system
- CacheStore - Token storage mechanism