Use this guide when a login is challenged or fails. For token expiry and refresh behavior, see Tokens and Sessions.
Risk-Based Authentication
AuthOS includes an intelligent Risk Engine that evaluates login attempts in real-time. When suspicious activity is detected, the platform may require additional verification through MFA, even if the user’s credentials are correct.
Understanding Risk Assessment
The Risk Engine evaluates multiple signals during every authentication attempt:
- Geographic Location: Logins from new countries or impossible travel patterns
- Device Trust: Logins from previously unseen devices
- Velocity Detection: Rapid login attempts or suspicious patterns
- User Agent Analysis: Unusual browsers or automation tools
Based on the risk score (0-100), the platform takes one of these actions:
| Risk Score | Action | What Happens |
|---|---|---|
| 0-40 | Allow | Login succeeds normally |
| 41-60 | Log Only | Login succeeds, event logged for review |
| 61-80 | Challenge MFA | Additional MFA verification required |
| 81-100 | Block | Login denied |
Handling MFA Challenges
When the Risk Engine determines additional verification is needed, authentication responses will include special indicators:
Password Login with Risk Challenge
async function handlePasswordLogin(email: string, password: string) {
try {
const response = await sso.auth.login({ email, password });
// Check if MFA is required due to risk assessment
if (response.expires_in === 300) {
// This is a pre-auth token (5 minutes expiry)
// User needs to provide MFA code
return {
type: 'mfa_required',
preauthToken: response.access_token,
message: 'Additional verification required'
};
}
// Normal login - no MFA required
sso.setAuthToken(response.access_token);
localStorage.setItem('sso_access_token', response.access_token);
localStorage.setItem('sso_refresh_token', response.refresh_token);
return { type: 'success' };
} catch (error) {
return { type: 'error', error };
}
}
Magic Link with Risk Challenge
async function handleMagicLinkVerification(token: string) {
try {
const response = await sso.magicLinks.verify(token);
// Check if response indicates MFA is required
if (response.requires_mfa) {
return {
type: 'mfa_required',
preauthToken: response.preauth_token,
message: response.message || 'Additional verification required due to unusual activity'
};
}
// Normal verification - store tokens
sso.setAuthToken(response.access_token);
localStorage.setItem('sso_access_token', response.access_token);
localStorage.setItem('sso_refresh_token', response.refresh_token);
return { type: 'success' };
} catch (error) {
return { type: 'error', error };
}
}
Completing MFA Verification
Once you have a pre-auth token from a risk challenge, prompt the user for their MFA code:
async function completeMfaChallenge(preauthToken: string, mfaCode: string) {
try {
const tokens = await sso.auth.verifyMfa(preauthToken, mfaCode);
// MFA verification successful - store full tokens
sso.setAuthToken(tokens.access_token);
localStorage.setItem('sso_access_token', tokens.access_token);
localStorage.setItem('sso_refresh_token', tokens.refresh_token);
return { type: 'success' };
} catch (error) {
if (error instanceof SsoApiError) {
if (error.statusCode === 401) {
return {
type: 'invalid_code',
message: 'Invalid MFA code. Please try again.'
};
} else if (error.statusCode === 410) {
return {
type: 'expired',
message: 'Verification expired. Please log in again.'
};
}
}
return { type: 'error', error };
}
}
Complete React Risk-Based Authentication
import { useState } from 'react';
import { SsoClient, SsoApiError } from '@drmhse/sso-sdk';
const sso = new SsoClient({
baseURL: process.env.REACT_APP_SSO_URL!
});
type AuthState =
| { step: 'credentials' }
| { step: 'mfa_required'; preauthToken: string; message: string }
| { step: 'success' };
export function RiskAwareLogin() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [authState, setAuthState] = useState<AuthState>({ step: 'credentials' });
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const response = await sso.auth.login({ email, password });
// Check for pre-auth token (MFA required)
if (response.expires_in === 300) {
setAuthState({
step: 'mfa_required',
preauthToken: response.access_token,
message: 'We detected unusual activity. Please verify your identity with MFA.'
});
setIsLoading(false);
} else {
// Normal login
sso.setAuthToken(response.access_token);
localStorage.setItem('sso_access_token', response.access_token);
localStorage.setItem('sso_refresh_token', response.refresh_token);
setAuthState({ step: 'success' });
window.location.href = '/dashboard';
}
} catch (err) {
setIsLoading(false);
if (err instanceof SsoApiError) {
setError(err.message);
} else {
setError('Login failed. Please try again.');
}
}
};
const handleMfaVerification = async (e: React.FormEvent) => {
e.preventDefault();
if (authState.step !== 'mfa_required') return;
setError('');
setIsLoading(true);
try {
const tokens = await sso.auth.verifyMfa(authState.preauthToken, mfaCode);
sso.setAuthToken(tokens.access_token);
localStorage.setItem('sso_access_token', tokens.access_token);
localStorage.setItem('sso_refresh_token', tokens.refresh_token);
setAuthState({ step: 'success' });
window.location.href = '/dashboard';
} catch (err) {
setIsLoading(false);
if (err instanceof SsoApiError) {
if (err.statusCode === 401) {
setError('Invalid MFA code. Please try again.');
} else if (err.statusCode === 410) {
setError('Verification expired. Please log in again.');
setAuthState({ step: 'credentials' });
} else {
setError(err.message);
}
}
}
};
// Step 1: Credentials
if (authState.step === 'credentials') {
return (
<div className="login-form">
<h2>Sign In</h2>
<form onSubmit={handleLogin}>
<div className="form-group">
<label>Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
/>
</div>
<div className="form-group">
<label>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
{error && <div className="alert alert-error">{error}</div>}
</div>
);
}
// Step 2: MFA Challenge
if (authState.step === 'mfa_required') {
return (
<div className="mfa-challenge">
<h2>Additional Verification Required</h2>
<div className="alert alert-warning">
<p>{authState.message}</p>
</div>
<p>Please enter your 6-digit MFA code from your authenticator app:</p>
<form onSubmit={handleMfaVerification}>
<div className="form-group">
<label>MFA Code</label>
<input
type="text"
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
maxLength={6}
pattern="\d{6}"
required
autoFocus
/>
</div>
<button type="submit" disabled={isLoading || mfaCode.length !== 6}>
{isLoading ? 'Verifying...' : 'Verify'}
</button>
</form>
{error && <div className="alert alert-error">{error}</div>}
<div className="help-text">
<p>
This additional step is required because we detected unusual activity,
such as a login from a new location or device.
</p>
</div>
<button
className="secondary-button"
onClick={() => setAuthState({ step: 'credentials' })}
>
Cancel and try again
</button>
</div>
);
}
return null;
}
OAuth Flow with Risk Assessment
OAuth flows can also trigger MFA challenges. Handle this in your callback:
export function OAuthCallback() {
const [status, setStatus] = useState<'processing' | 'mfa_required' | 'success'>('processing');
const [preauthToken, setPreauthToken] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [error, setError] = useState('');
useEffect(() => {
handleCallback();
}, []);
const handleCallback = async () => {
// Check query parameters for MFA challenge
const params = new URLSearchParams(window.location.search);
const preauth = params.get('preauth_token');
const mfaRequired = params.get('mfa_required');
if (mfaRequired === 'true' && preauth) {
// Risk assessment or user settings triggered MFA requirement
setPreauthToken(preauth);
setStatus('mfa_required');
return;
}
// Normal tokens are returned in the fragment (#)
const hashParams = new URLSearchParams(window.location.hash.substring(1));
const accessToken = hashParams.get('access_token');
const refreshToken = hashParams.get('refresh_token');
if (accessToken) {
localStorage.setItem('sso_access_token', accessToken);
if (refreshToken) {
localStorage.setItem('sso_refresh_token', refreshToken);
}
sso.setAuthToken(accessToken);
setStatus('success');
window.location.href = '/dashboard';
}
};
const handleMfaSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const tokens = await sso.auth.verifyMfa(preauthToken, mfaCode);
localStorage.setItem('sso_access_token', tokens.access_token);
localStorage.setItem('sso_refresh_token', tokens.refresh_token);
sso.setAuthToken(tokens.access_token);
window.location.href = '/dashboard';
} catch (err) {
setError('MFA verification failed');
}
};
if (status === 'mfa_required') {
return (
<div className="mfa-challenge">
<h2>Additional Verification Required</h2>
<p>Please enter your MFA code to complete login:</p>
<form onSubmit={handleMfaSubmit}>
<input
type="text"
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
placeholder="000000"
maxLength={6}
required
/>
<button type="submit">Verify</button>
</form>
{error && <div className="error">{error}</div>}
</div>
);
}
return <div>Processing authentication...</div>;
}
User Experience Best Practices
- Clear Communication: Explain why additional verification is needed
- Contextual Messages: Show location/device information that triggered the challenge
- Backup Options: Allow users to use backup codes if they don’t have their authenticator
- Session Persistence: Remember trusted devices to reduce friction for returning users
- Graceful Fallbacks: Provide alternative authentication methods if MFA fails
Detecting Risk Challenges
Pre-auth tokens have a short expiration (5 minutes) to indicate MFA is required:
function isPreAuthToken(response: RefreshTokenResponse): boolean {
// Pre-auth tokens expire in 5 minutes (300 seconds)
// Normal tokens expire in 15 minutes (900 seconds)
return response.expires_in === 300;
}
function isMfaRequired(response: any): boolean {
return response.mfa_required === true || isPreAuthToken(response);
}
Handling Backup Codes
Users can use backup codes instead of TOTP codes during MFA challenges:
async function verifyWithBackupCode(preauthToken: string, backupCode: string) {
try {
// Backup codes are treated the same as TOTP codes
const tokens = await sso.auth.verifyMfa(preauthToken, backupCode);
return { success: true, tokens };
} catch (error) {
if (error instanceof SsoApiError && error.statusCode === 401) {
return {
success: false,
error: 'Invalid backup code or code already used'
};
}
throw error;
}
}
Testing Risk-Based Flows
When testing risk-based authentication:
- New Device: Clear cookies to simulate a new device
- New Location: Use a VPN to test geographic anomaly detection
- Rapid Attempts: Test velocity detection with multiple quick login attempts
- Monitor Mode: Set organization risk settings to “monitor” mode during development
// Set organization to monitor mode for testing
await sso.organizations.updateRiskSettings('acme-corp', {
enabled: true,
mode: 'monitor', // Logs risk but doesn't enforce
block_threshold: 80,
mfa_threshold: 60
});
Error Handling
import { SsoApiError } from '@drmhse/sso-sdk';
try {
await sso.user.getProfile();
} catch (error) {
if (error instanceof SsoApiError) {
switch (error.statusCode) {
case 401:
// Token expired or invalid
await refreshAccessToken();
break;
case 403:
// Insufficient permissions
console.error('Access denied');
break;
case 404:
// Resource not found
console.error('Resource not found');
break;
case 500:
// Server error
console.error('Server error, please try again later');
break;
default:
console.error(`Error: ${error.message}`);
}
} else {
// Network or other error
console.error('Network error:', error);
}
}
Redirect URI Configuration
Always whitelist your redirect URIs in the service configuration:
// When creating a service via API or your own management UI
const service = await sso.services.create('acme-corp', {
slug: 'main-app',
name: 'Main Application',
service_type: 'web',
redirect_uris: [
'https://app.acme.com/callback',
'https://app.acme.com/device-callback',
'http://localhost:3001/callback' // for development
]
});