Risk-Based Authentication Strategies
Implementing risk-based authentication that adapts security requirements to the threat level of each request, balancing security with user experience in TypeScript.
Traditional authentication is binary: either the user provides the correct credentials and gets in, or they do not. This model fails to account for context. Logging in from a recognized device at a usual time is fundamentally different from logging in from a new device in a different country at 3 AM. Risk-based authentication (RBA) adapts the authentication requirements to the risk level of each specific request, applying stronger checks when the risk is higher and reducing friction when the risk is low.
At Klivvr, Oasis implements risk-based authentication as part of the identity management layer. Every authentication attempt is evaluated against a risk model that considers device fingerprints, behavioral patterns, geolocation, and historical data. This article describes the architecture, the risk signals we evaluate, the decision engine, and the step-up authentication mechanisms that kick in when elevated risk is detected.
The Risk Evaluation Pipeline
Every authentication request passes through a risk evaluation pipeline before the authentication decision is made. The pipeline collects signals, scores them, and produces a risk level that determines the authentication requirements.
interface AuthenticationRequest {
userId: string;
credentials: Credentials;
context: AuthenticationContext;
}
interface AuthenticationContext {
ipAddress: string;
userAgent: string;
deviceFingerprint: DeviceFingerprint;
timestamp: Date;
geoLocation: GeoLocation | null;
sessionHistory: SessionInfo[];
}
interface RiskEvaluation {
requestId: string;
userId: string;
riskScore: number;
riskLevel: "low" | "medium" | "high" | "critical";
signals: EvaluatedSignal[];
requiredActions: AuthenticationAction[];
evaluatedAt: Date;
}
type AuthenticationAction =
| "allow"
| "require_mfa"
| "require_email_verification"
| "require_sms_verification"
| "block_and_notify"
| "require_biometric";
class RiskEvaluationPipeline {
private evaluators: RiskSignalEvaluator[] = [];
register(evaluator: RiskSignalEvaluator): void {
this.evaluators.push(evaluator);
}
async evaluate(request: AuthenticationRequest): Promise<RiskEvaluation> {
const signals: EvaluatedSignal[] = [];
const evaluationPromises = this.evaluators.map(async (evaluator) => {
try {
return await evaluator.evaluate(request);
} catch (error) {
return {
name: evaluator.name,
score: 50,
weight: 0.5,
details: `Evaluation failed: ${(error as Error).message}`,
failedToEvaluate: true,
};
}
});
const results = await Promise.all(evaluationPromises);
signals.push(...results);
const riskScore = this.calculateCompositeScore(signals);
const riskLevel = this.scoreToLevel(riskScore);
const requiredActions = this.determineActions(riskLevel, signals);
return {
requestId: generateUUID(),
userId: request.userId,
riskScore,
riskLevel,
signals,
requiredActions,
evaluatedAt: new Date(),
};
}
private calculateCompositeScore(signals: EvaluatedSignal[]): number {
const totalWeight = signals.reduce((sum, s) => sum + s.weight, 0);
if (totalWeight === 0) return 50;
const weightedSum = signals.reduce(
(sum, s) => sum + s.score * s.weight,
0
);
return Math.min(100, Math.max(0, weightedSum / totalWeight));
}
private scoreToLevel(
score: number
): "low" | "medium" | "high" | "critical" {
if (score <= 20) return "low";
if (score <= 50) return "medium";
if (score <= 80) return "high";
return "critical";
}
private determineActions(
level: string,
signals: EvaluatedSignal[]
): AuthenticationAction[] {
switch (level) {
case "low":
return ["allow"];
case "medium":
return ["require_mfa"];
case "high":
return ["require_mfa", "require_email_verification"];
case "critical":
return ["block_and_notify"];
default:
return ["require_mfa"];
}
}
}The evaluators run in parallel to minimize latency. If an evaluator fails (for example, the geolocation service is down), its signal defaults to a moderate score with reduced weight rather than blocking the authentication entirely. This ensures the system degrades gracefully.
Device Trust and Recognition
One of the most powerful risk signals is whether the device is recognized. A returning device that the user has authenticated from before is significantly lower risk than a completely new device.
interface TrustedDevice {
id: string;
userId: string;
fingerprint: string;
name: string;
firstSeenAt: Date;
lastSeenAt: Date;
successfulAuthCount: number;
trustLevel: "new" | "recognized" | "trusted";
}
class DeviceTrustEvaluator implements RiskSignalEvaluator {
name = "device-trust";
constructor(private deviceStore: DeviceStore) {}
async evaluate(request: AuthenticationRequest): Promise<EvaluatedSignal> {
const fingerprint = request.context.deviceFingerprint;
const knownDevice = await this.deviceStore.findByFingerprint(
request.userId,
this.computeFingerprintHash(fingerprint)
);
if (!knownDevice) {
return {
name: this.name,
score: 70,
weight: 2.0,
details: "Unknown device",
failedToEvaluate: false,
};
}
if (knownDevice.trustLevel === "trusted") {
return {
name: this.name,
score: 5,
weight: 2.0,
details: `Trusted device, ${knownDevice.successfulAuthCount} prior authentications`,
failedToEvaluate: false,
};
}
if (knownDevice.trustLevel === "recognized") {
const daysSinceLastSeen =
(Date.now() - knownDevice.lastSeenAt.getTime()) / (1000 * 60 * 60 * 24);
const score = daysSinceLastSeen > 30 ? 40 : 20;
return {
name: this.name,
score,
weight: 2.0,
details: `Recognized device, last seen ${Math.floor(daysSinceLastSeen)} days ago`,
failedToEvaluate: false,
};
}
return {
name: this.name,
score: 50,
weight: 2.0,
details: "New device with limited history",
failedToEvaluate: false,
};
}
private computeFingerprintHash(fingerprint: DeviceFingerprint): string {
const components = [
fingerprint.screenResolution,
fingerprint.timezone,
fingerprint.language,
fingerprint.canvasHash,
fingerprint.webglHash,
].join("|");
return crypto.createHash("sha256").update(components).digest("hex");
}
}
class DeviceTrustManager {
constructor(private deviceStore: DeviceStore) {}
async recordSuccessfulAuth(
userId: string,
fingerprint: DeviceFingerprint
): Promise<void> {
const hash = this.computeFingerprintHash(fingerprint);
const existing = await this.deviceStore.findByFingerprint(userId, hash);
if (!existing) {
await this.deviceStore.create({
id: generateUUID(),
userId,
fingerprint: hash,
name: this.deriveDeviceName(fingerprint),
firstSeenAt: new Date(),
lastSeenAt: new Date(),
successfulAuthCount: 1,
trustLevel: "new",
});
return;
}
existing.lastSeenAt = new Date();
existing.successfulAuthCount += 1;
if (existing.successfulAuthCount >= 5 && existing.trustLevel === "new") {
existing.trustLevel = "recognized";
}
if (existing.successfulAuthCount >= 20 && existing.trustLevel === "recognized") {
existing.trustLevel = "trusted";
}
await this.deviceStore.update(existing);
}
private computeFingerprintHash(fingerprint: DeviceFingerprint): string {
const components = [
fingerprint.screenResolution,
fingerprint.timezone,
fingerprint.language,
fingerprint.canvasHash,
fingerprint.webglHash,
].join("|");
return crypto.createHash("sha256").update(components).digest("hex");
}
private deriveDeviceName(fingerprint: DeviceFingerprint): string {
const ua = fingerprint.userAgent;
if (ua.includes("iPhone")) return "iPhone";
if (ua.includes("Android")) return "Android Device";
if (ua.includes("Mac")) return "Mac";
if (ua.includes("Windows")) return "Windows PC";
return "Unknown Device";
}
}Device trust builds over time. A new device starts with no trust and earns it through repeated successful authentications. This graduated approach avoids the common problem of requiring MFA on every login for a device the user uses daily.
Geolocation and Velocity Analysis
Geolocation analysis detects impossible or unlikely travel patterns. If a user authenticates from Cairo and then from Tokyo 30 minutes later, something is wrong. Velocity checks catch this pattern.
class GeoVelocityEvaluator implements RiskSignalEvaluator {
name = "geo-velocity";
constructor(private sessionHistory: SessionHistoryService) {}
async evaluate(request: AuthenticationRequest): Promise<EvaluatedSignal> {
const recentSessions = await this.sessionHistory.getRecent(
request.userId,
24
);
if (recentSessions.length === 0 || !request.context.geoLocation) {
return {
name: this.name,
score: 10,
weight: 1.5,
details: "No recent sessions to compare",
failedToEvaluate: false,
};
}
const lastSession = recentSessions[0];
if (!lastSession.geoLocation) {
return {
name: this.name,
score: 15,
weight: 1.5,
details: "Previous session had no geolocation",
failedToEvaluate: false,
};
}
const distanceKm = this.haversineDistance(
lastSession.geoLocation,
request.context.geoLocation
);
const timeDiffHours =
(request.context.timestamp.getTime() - lastSession.timestamp.getTime()) /
(1000 * 60 * 60);
const impliedSpeedKmh = timeDiffHours > 0 ? distanceKm / timeDiffHours : 0;
if (impliedSpeedKmh > 1000) {
return {
name: this.name,
score: 95,
weight: 1.5,
details: `Impossible travel: ${Math.round(distanceKm)}km in ${timeDiffHours.toFixed(1)}h (${Math.round(impliedSpeedKmh)}km/h)`,
failedToEvaluate: false,
};
}
if (impliedSpeedKmh > 500) {
return {
name: this.name,
score: 60,
weight: 1.5,
details: `Suspicious travel speed: ${Math.round(impliedSpeedKmh)}km/h`,
failedToEvaluate: false,
};
}
return {
name: this.name,
score: 5,
weight: 1.5,
details: `Normal travel pattern: ${Math.round(distanceKm)}km`,
failedToEvaluate: false,
};
}
private haversineDistance(a: GeoLocation, b: GeoLocation): number {
const R = 6371;
const dLat = this.toRad(b.latitude - a.latitude);
const dLon = this.toRad(b.longitude - a.longitude);
const aCalc =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(a.latitude)) *
Math.cos(this.toRad(b.latitude)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(aCalc), Math.sqrt(1 - aCalc));
return R * c;
}
private toRad(deg: number): number {
return deg * (Math.PI / 180);
}
}The velocity check uses the Haversine formula to calculate the great-circle distance between two points and divides by the time elapsed. An implied speed greater than 1000 km/h is physically impossible (even commercial jets top out around 900 km/h), so it indicates that either the user is using a VPN, their account has been compromised, or they are sharing credentials.
Step-Up Authentication
When the risk evaluation determines that standard credentials are insufficient, the system triggers step-up authentication. This requires the user to complete additional verification steps proportional to the detected risk.
interface StepUpChallenge {
id: string;
userId: string;
type: AuthenticationAction;
status: "pending" | "completed" | "expired" | "failed";
createdAt: Date;
expiresAt: Date;
completedAt: Date | null;
attempts: number;
maxAttempts: number;
}
class StepUpAuthenticationService {
constructor(
private challengeStore: ChallengeStore,
private mfaService: MFAService,
private emailService: EmailVerificationService,
private smsService: SMSVerificationService,
private notificationService: NotificationService
) {}
async initiateStepUp(
userId: string,
requiredActions: AuthenticationAction[]
): Promise<StepUpSession> {
const challenges: StepUpChallenge[] = [];
for (const action of requiredActions) {
if (action === "allow") continue;
if (action === "block_and_notify") {
await this.notificationService.notifyAccountSuspicious(userId);
throw new AuthenticationBlockedError(
"Authentication blocked due to suspicious activity"
);
}
const challenge = await this.createChallenge(userId, action);
challenges.push(challenge);
}
return {
sessionId: generateUUID(),
userId,
challenges,
allCompleted: challenges.length === 0,
};
}
private async createChallenge(
userId: string,
action: AuthenticationAction
): Promise<StepUpChallenge> {
const challenge: StepUpChallenge = {
id: generateUUID(),
userId,
type: action,
status: "pending",
createdAt: new Date(),
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
completedAt: null,
attempts: 0,
maxAttempts: 3,
};
await this.challengeStore.save(challenge);
switch (action) {
case "require_mfa":
await this.mfaService.sendChallenge(userId);
break;
case "require_email_verification":
await this.emailService.sendVerificationCode(userId);
break;
case "require_sms_verification":
await this.smsService.sendVerificationCode(userId);
break;
}
return challenge;
}
async verifyChallenge(
challengeId: string,
response: string
): Promise<boolean> {
const challenge = await this.challengeStore.findById(challengeId);
if (challenge.status !== "pending") {
throw new ChallengeNotPendingError(challenge.status);
}
if (new Date() > challenge.expiresAt) {
challenge.status = "expired";
await this.challengeStore.save(challenge);
throw new ChallengeExpiredError();
}
challenge.attempts += 1;
let verified = false;
switch (challenge.type) {
case "require_mfa":
verified = await this.mfaService.verifyCode(challenge.userId, response);
break;
case "require_email_verification":
verified = await this.emailService.verifyCode(challenge.userId, response);
break;
case "require_sms_verification":
verified = await this.smsService.verifyCode(challenge.userId, response);
break;
}
if (verified) {
challenge.status = "completed";
challenge.completedAt = new Date();
} else if (challenge.attempts >= challenge.maxAttempts) {
challenge.status = "failed";
}
await this.challengeStore.save(challenge);
return verified;
}
}Each challenge has a limited number of attempts and a short expiration window. If the user fails all attempts or the challenge expires, the authentication is denied and the user must start the process over. For critical risk levels, the account is temporarily suspended and the user is notified through all registered channels.
Conclusion
Risk-based authentication is a significant improvement over static authentication policies. By evaluating the context of each authentication attempt, the system can apply the appropriate level of security without imposing unnecessary burden on low-risk requests.
The key components, a parallel risk evaluation pipeline, device trust progression, geolocation velocity analysis, and proportional step-up challenges, work together to create a system that is both more secure and more convenient than traditional approaches. Low-risk users on trusted devices experience seamless authentication, while suspicious attempts trigger additional verification that prevents unauthorized access.
The most important lesson from implementing RBA in Oasis is that the risk model must be continuously tuned. Initial thresholds will be imperfect. Monitoring false positive rates (legitimate users being challenged unnecessarily) and false negative rates (suspicious attempts that were not challenged) provides the data needed to refine the model over time. A risk-based authentication system is never finished; it evolves with the threat landscape.
Related Articles
Improving Customer Conversion Through Better Onboarding
Data-driven strategies for improving customer conversion rates during fintech onboarding, from A/B testing frameworks to personalization and real-time optimization.
Integrating Third-Party Verification APIs
Practical strategies for integrating third-party identity verification APIs, covering adapter patterns, error handling, rate limiting, and provider management in TypeScript.
Data Privacy in Customer Onboarding
Strategies for protecting customer data during the onboarding process, covering data minimization, encryption, consent management, and regulatory compliance.