Authentication doesn't end when a user logs in. A session that was legitimate at login may become suspicious during its lifetime: the user's IP address changes to a different country, they suddenly start downloading data at high volume, or they attempt to access administrative functions they've never touched before. Continuous authentication is the practice of evaluating session trustworthiness throughout a session's lifetime, not just at its start, and taking action when the evaluation changes.
The challenge is doing this without constantly interrupting users with re-authentication prompts. The techniques covered here — risk scoring, step-up auth, silent re-auth, and privilege elevation — form a layered approach that's largely invisible to legitimate users while providing meaningful signals for detecting and responding to compromised sessions.
Risk scoring model
Risk scoring assigns a numeric risk level to each session or request based on observable signals. Low-risk requests proceed normally. High-risk requests trigger step-up authentication.
interface RiskSignals {
userId: string;
sessionId: string;
ipAddress: string;
userAgent: string;
action: string;
timestamp: number;
}
interface RiskScore {
score: number; // 0-100
level: 'low' | 'medium' | 'high' | 'critical';
factors: string[]; // for audit logging
}
async function computeRiskScore(signals: RiskSignals): Promise {
let score = 0;
const factors: string[] = [];
// IP reputation
const ipReputation = await checkIpReputation(signals.ipAddress);
if (ipReputation.isDataCenter) { score += 15; factors.push('datacenter_ip'); }
if (ipReputation.isTor) { score += 30; factors.push('tor_exit_node'); }
if (ipReputation.isKnownBad) { score += 50; factors.push('known_malicious_ip'); }
// Geographic anomaly
const recentLocations = await getRecentLocations(signals.userId, 7);
const geoAnomaly = await detectGeoAnomaly(signals.ipAddress, recentLocations);
if (geoAnomaly.isImpossibleTravel) { score += 40; factors.push('impossible_travel'); }
else if (geoAnomaly.isNewCountry) { score += 20; factors.push('new_country'); }
// Behavioral anomaly
const actionFrequency = await getActionFrequency(signals.userId, signals.action, 60);
if (actionFrequency.isUnusuallyHigh) { score += 25; factors.push('unusual_action_rate'); }
// Sensitive action
const sensitiveActions = ['export_data', 'change_email', 'delete_account', 'add_payment'];
if (sensitiveActions.includes(signals.action)) { score += 20; factors.push('sensitive_action'); }
const level = score >= 70 ? 'critical' : score >= 50 ? 'high' : score >= 25 ? 'medium' : 'low';
return { score: Math.min(score, 100), level, factors };
}
Step-up authentication triggers
Step-up authentication asks users to provide an additional factor for high-risk actions, without forcing a full re-login. The user remains in their existing session; they just need to prove identity at a higher assurance level for this specific action.
// Middleware: require step-up auth for sensitive endpoints
async function requireStepUp(
req: Request,
res: Response,
next: NextFunction
): Promise {
const session = req.session!;
const riskScore = await computeRiskScore({
userId: session.userId,
sessionId: session.id,
ipAddress: req.ip,
userAgent: req.headers['user-agent'] ?? '',
action: req.route.path,
timestamp: Date.now(),
});
if (riskScore.level === 'critical' || riskScore.level === 'high') {
// Store the intended action so we can resume after step-up
await redis.setex(
`stepup_intent:${session.id}`,
300,
JSON.stringify({ url: req.url, method: req.method, body: req.body })
);
return res.status(403).json({
error: 'STEP_UP_REQUIRED',
reason: riskScore.factors,
stepUpUrl: `/auth/step-up?session=${session.id}&return=${encodeURIComponent(req.url)}`,
});
}
// Record that this action was taken at current risk level
session.lastHighRiskActionAt = Date.now();
next();
}
Silent re-authentication
For medium-risk scenarios, silent re-authentication can verify the user's identity without visible interruption. Using OIDC's prompt=none parameter, you can send a silent authorization request to your auth server. If the user's session there is still valid, it returns a new token immediately. If the session has expired, it returns an error — at which point you show a login prompt.
async function silentReauth(userId: string): Promise {
return new Promise((resolve) => {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: `${window.location.origin}/auth/silent-callback`,
scope: 'openid',
prompt: 'none', // do not show any UI — fail if the session is gone
login_hint: userId,
});
const timeout = setTimeout(() => {
document.body.removeChild(iframe);
resolve(false); // timeout = session is gone
}, 5000);
window.addEventListener('message', function handler(event) {
if (event.data.type !== 'silent_auth_result') return;
clearTimeout(timeout);
document.body.removeChild(iframe);
window.removeEventListener('message', handler);
resolve(event.data.success);
});
iframe.src = `https://auth.example.com/authorize?${params}`;
document.body.appendChild(iframe);
});
}
Privilege elevation pattern
For actions that require the highest assurance level — changing billing details, revoking API keys, accessing audit logs — model privilege elevation explicitly. The user has a normal session token and a separate short-lived elevated token issued after step-up authentication.
// Issue an elevated token after step-up auth
async function issueElevatedToken(userId: string, sessionId: string): Promise {
const token = crypto.randomBytes(32).toString('hex');
await redis.setex(
`elevated:${sessionId}`,
900, // 15-minute window — elevated privilege expires quickly
JSON.stringify({
userId,
elevatedAt: Date.now(),
stepUpMethod: 'totp', // record how elevation was achieved
})
);
return token;
}
// Check elevation on privileged endpoints
async function requireElevation(req: Request, res: Response, next: NextFunction) {
const elevatedToken = req.headers['x-elevated-token'] as string;
if (!elevatedToken) {
return res.status(403).json({ error: 'ELEVATION_REQUIRED' });
}
const elevation = await redis.get(`elevated:${req.session!.id}`);
if (!elevation) {
return res.status(403).json({ error: 'ELEVATION_EXPIRED' });
}
const { elevatedAt } = JSON.parse(elevation);
if (Date.now() - elevatedAt > 15 * 60 * 1000) {
await redis.del(`elevated:${req.session!.id}`);
return res.status(403).json({ error: 'ELEVATION_EXPIRED' });
}
next();
}
The 15-minute elevation window is a balance between security and usability. An administrator performing a batch of privileged operations shouldn't have to re-authenticate for each one. But the window must be short enough that a borrowed unlocked screen doesn't give an attacker extended privilege. Scope the window to the task: 5 minutes for payment method changes, 30 minutes for administrative tasks that may take longer.