How Logto's Sentinel System Handles Rate Limiting and Brute-Force Protection
Logto's sentinel system for rate limiting is a database-backed, policy-driven subsystem that tracks failed authentication attempts in a sentinel_activities table, blocks further attempts after a configurable threshold, and automatically expires the block after a set duration to protect against brute-force attacks.
Logto implements a robust sentinel system for rate limiting to safeguard authentication flows from brute-force attacks and abuse. This subsystem operates as a tenant-wide policy engine that monitors sign-in activities, enforces lockout mechanisms, and prevents spam through integrated message rate limiting. According to the logto-io/logto source code, the implementation centers around the BasicSentinel class and a dedicated MessageRateGuard for comprehensive protection.
Core Components of the Sentinel System
Sentinel Policy Configuration
The sentinel behavior is governed by a Sentinel Policy stored in the tenant's sign-in experience configuration. Default values are defined in packages/schemas/src/consts/sentinel.ts, specifying maxAttempts: 100 and lockoutDuration: 60 minutes. Administrators can override these defaults per-tenant through the sign-in experience settings.
Activity Recording and Storage
Every authentication-related action generates an activity record stored in the sentinel_activities table. The activity payload follows the ActivityReport type defined in packages/schemas/src/types/sentinel.ts, capturing the target hash, action type, result (success or failure), and timestamp. This persistent storage enables the system to track failed attempts across distributed instances.
How BasicSentinel Implements Rate Limiting
The Decision Workflow
The BasicSentinel class in packages/core/src/sentinel/basic-sentinel.ts serves as the default implementation. Its workflow consists of two primary methods:
- assertAction – Validates that the incoming action belongs to either the pooled or isolated action categories.
- decide – Determines whether to allow or block the request by checking existing blocks and counting recent failed attempts.
The decide method queries the sentinel_activities table to check if the target is already blocked via isBlocked. If not blocked, it counts failed attempts from the last hour for the same pooled actions. When the count reaches maxAttempts, it returns a Blocked decision with an expiration timestamp calculated as now + lockoutDuration. Otherwise, it returns Allowed.
import BasicSentinel from '#src/sentinel/basic-sentinel.js';
import { type ActivityReport } from '@logto/schemas';
// Example: reporting a password-verification activity
async function verifyPassword(
sentinel: BasicSentinel,
targetHash: string,
passwordCorrect: boolean
) {
const activity: ActivityReport = {
targetType: SentinelActivityTargetType.User,
targetHash,
action: SentinelActivityAction.Password,
actionResult: passwordCorrect
? SentinelActionResult.Success
: SentinelActionResult.Failed,
payload: {}
};
// Returns [decision, expiresAt]
const [decision, expiresAt] = await sentinel.reportActivity(activity);
if (decision === SentinelDecision.Blocked) {
throw new Error(`Account locked until ${new Date(expiresAt).toISOString()}`);
}
// Continue with normal login flow...
}
Action Isolation for MFA Protection
Multi-factor authentication actions receive special treatment through action isolation. Unlike password attempts that pool together, MFA actions (TOTP, WebAuthn, backup codes) use separate activity pools via the isolatedActionArrays map and getActionGuard helper. This prevents a lockout on one authentication factor from affecting others, maintaining security without compromising availability.
Message Rate Limiting with MessageRateGuard
Beyond login attempt throttling, Logto protects against outbound message spam through MessageRateGuard located in packages/core/src/sentinel/message-rate-guard.ts. This guard enforces defaultMessageRateLimitPolicy to limit verification codes and invitation emails per recipient. Unlike the main sentinel policy, message rate limits are not tenant-configurable. The withMessageRateGuard wrapper checks send counts before transmission and records successful sends to prevent abuse.
import { withMessageRateGuard } from '#src/sentinel/message-rate-guard.js';
import { sendVerificationEmail } from './email';
// Guard a verification-code email send
async function sendCode(email: string, guard: MessageRateGuard) {
await withMessageRateGuard(guard, {
action: SentinelActivityAction.VerificationCode,
recipient: email,
onRateLimited: () => console.warn('Message rate limit exceeded')
}, async () => {
await sendVerificationEmail(email);
});
}
Integration with Sign-In Experience
The sentinel system integrates with Logto's management APIs through the sign-in experience route at packages/core/src/routes/sign-in-experience/index.ts. This route reads the sentinelPolicy configuration and injects it into BasicSentinel instances. The admin console parses these policies for display using the parser in packages/console/src/pages/SignInExperience/PageContent/utils/parser.ts, allowing administrators to configure protection levels through the UI.
Summary
- The sentinel system uses a tenant-configurable policy with default values of 100 max attempts and 60-minute lockout durations defined in
packages/schemas/src/consts/sentinel.ts. BasicSentinelinpackages/core/src/sentinel/basic-sentinel.tstracks failed attempts in thesentinel_activitiestable and issuesBlockeddecisions when thresholds are exceeded.- MFA actions are isolated into separate pools to prevent cascading lockouts across authentication factors.
MessageRateGuardprovides additional protection for outbound communications using non-configurable rate limits.- Integration occurs through the sign-in experience routes and admin console parsers for seamless policy management.
Frequently Asked Questions
What triggers a block in Logto's sentinel system?
A block triggers when the number of failed attempts for a specific target (user/email/phone hash) reaches the maxAttempts threshold (default 100) within a one-hour window. The decide method in BasicSentinel counts recent failed activities from the sentinel_activities table and returns a Blocked decision with an expiration timestamp set to the current time plus the lockoutDuration.
How does the sentinel system handle MFA attempts differently from password attempts?
The sentinel system isolates MFA actions (TOTP, WebAuthn, backup codes) into separate activity pools using the isolatedActionArrays map in BasicSentinel. This ensures that excessive failed attempts on one MFA factor do not lock out other factors or the primary password authentication, maintaining layered security without single points of failure.
Can administrators customize the rate limiting thresholds?
Yes, administrators can override the default sentinel policy values stored in the sign-in experience configuration. While the defaults (maxAttempts: 100, lockoutDuration: 60min) are defined in packages/schemas/src/consts/sentinel.ts, the system reads custom policies from the tenant configuration in packages/core/src/routes/sign-in-experience/index.ts. However, the MessageRateGuard limits for outbound messages are not tenant-configurable.
Where does the sentinel system store activity records?
Activity records persist in the sentinel_activities database table, which tracks every sign-in-related action including passwords, verification codes, and MFA attempts. The schema stores target hashes, action types, results, and timestamps, enabling BasicSentinel to query historical attempts when making block decisions through SQL logic in the isBlocked and decide methods.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →