Setting up Rate Limiting for API Protection with Logto: Implementation Guide
Logto protects its outbound messaging APIs using a system-level per-recipient rate limit enforced by the MessageRateGuard sentinel, which applies a default cap of 10 sends per 10-minute rolling window and returns HTTP 429 when exceeded.
Logto, the open-source identity infrastructure from logto-io/logto, includes built-in rate limiting to prevent abuse of verification codes and organization invitations. This mechanism operates at the core service level through the MessageRateGuard sentinel, ensuring consistent protection across all tenant deployments without requiring manual configuration by end users.
Understanding the MessageRateGuard Architecture
The rate limiting system centers on the MessageRateGuard class located in packages/core/src/sentinel/message-rate-guard.ts. This sentinel intercepts outgoing message requests before they reach external providers, maintaining a count of recent sends per recipient in the sentinel_activities table.
Policy Definition and Configuration
The default rate limiting policy is immutable and defined in packages/schemas/src/consts/message-rate-limit.ts:
export const defaultMessageRateLimitPolicy = Object.freeze({
sendWindow: 600, // 10-minute rolling window
maxSendsPerRecipient: 10, // up to 10 sends per recipient in that window
});
This policy is not tenant-configurable. Only Logto operators can apply partial overrides via the internal messageRateLimitOverride configuration stored in the logto_configs table. When present, buildMessageRateGuard merges the override with the default policy, allowing specific fields to be adjusted while retaining others.
Recipient Normalization and Hashing
Before counting sends, Logto normalizes the recipient identifier to prevent circumvention through formatting variations. The normalizeRecipient function in packages/core/src/sentinel/message-rate-guard.ts handles this transformation:
const normalizeRecipient = (recipient: string) => {
const trimmed = recipient.trim();
// Email -> lower-case; otherwise treat as phone number
return trimmed.includes('@') ? trimmed.toLowerCase() : parsePhoneNumber(trimmed);
};
The normalized value is then hashed using SHA-256 to create a targetHash, which serves as the lookup key in the sentinel_activities table. This ensures that email addresses differing only in case or phone numbers with varying formats share the same rate limit bucket.
Enforcement Logic
The guard method queries the database for recent activities matching the targetHash, action type (e.g., VerificationCodeSend), and the policy window:
async guard(action, recipient) {
const targetHash = await sha256(normalizeRecipient(recipient));
const count = await trySafe(
this.queries.countActivities({
targetType: SentinelActivityTargetType.User,
targetHash,
action,
windowSeconds: this.policy.sendWindow,
})
);
if (count !== undefined && count >= this.policy.maxSendsPerRecipient) {
throw new RequestError({ code: 'request.message_rate_limited', status: 429 });
}
}
If the count exceeds maxSendsPerRecipient, the guard throws a 429 RequestError with the code request.message_rate_limited. The system employs a "fails open" strategy: if the database query fails, the error is caught and the request is allowed to proceed, preventing infrastructure issues from blocking legitimate traffic.
Implementing Rate Limiting in Your Code
Logto provides two primary patterns for integrating rate limiting into custom flows: a convenient wrapper for standard use cases and direct guard instantiation for complex scenarios.
Using the withMessageRateGuard Wrapper
The withMessageRateGuard function combines the guard check, the send operation, and the recording step into a single call. It also supports an onRateLimited hook for custom logging or metrics:
import { buildMessageRateGuard, withMessageRateGuard } from '#src/sentinel/message-rate-guard.js';
import { SentinelActivityAction } from '@logto/schemas';
async function sendCustomSms(recipient: string, content: string, queries) {
const guard = await buildMessageRateGuard(queries);
const send = async () => {
// Replace with your actual SMS provider call
await smsProvider.send(recipient, content);
return 'sent';
};
return withMessageRateGuard(guard, {
action: SentinelActivityAction.VerificationCodeSend,
recipient,
onRateLimited: () => console.warn('Rate limit hit for', recipient),
}, send);
}
After a successful send, withMessageRateGuard automatically calls guard.record() to persist the activity to the sentinel_activities table. Failures during the record step are ignored to prevent message delivery from being marked as failed due to logging issues.
Manual Guard Construction
For scenarios requiring fine-grained control, instantiate the guard directly using the factory helper:
const guard = await buildMessageRateGuard(queries);
await guard.guard(SentinelActivityAction.VerificationCodeSend, recipient);
// Perform send operation...
await guard.record(SentinelActivityAction.VerificationCodeSend, recipient);
This pattern is useful when you need to perform additional validation between the rate limit check and the actual send operation.
Where Rate Limiting is Applied in Logto
The withMessageRateGuard wrapper is integrated into every endpoint that dispatches verification codes or invitations:
POST /api/verification-codeinpackages/core/src/routes/verification/index.tsguards all verification code requests sent via the admin API.POST /api/verification-code(user self-service) inpackages/core/src/routes-me/verification-code.tsapplies the same protection to end-user initiated requests.- Organization invitations in
packages/core/src/libraries/organization-invitation.tswrap the email or SMS send operation to prevent invitation spam. - Experience API verification in
packages/core/src/routes/experience/verification-routes/verification-code-helpers.tsprotects verification flows initiated from the frontend experience.
All call sites use the same withMessageRateGuard pattern, ensuring unified rate limit behavior across the product.
Operator Overrides and Customization
While tenants cannot modify rate limits, operators can temporarily raise caps for specific deployments by inserting a configuration override:
INSERT INTO logto_configs (key, value)
VALUES ('messageRateLimitOverride', '{"maxSendsPerRecipient": 100}');
The buildMessageRateGuard function retrieves this override via queries.logtoConfigs.getMessageRateLimitOverride() and merges it with the default policy. Only the fields supplied in the override are changed; unspecified fields retain their default values. This override mechanism is not exposed via any public API, keeping the policy immutable for end-users while allowing operational flexibility.
Testing Your Rate Limit Configuration
The repository includes comprehensive tests in packages/core/src/sentinel/message-rate-guard.test.ts that verify the guard behavior. The following test confirms that recipients hitting the cap receive a 429 response:
it('rejects with 429 when the recipient is at the cap', async () => {
countActivities.mockResolvedValueOnce(3);
await expect(buildGuard().guard(action, recipient)).rejects.toMatchObject({
code: 'request.message_rate_limited',
status: 429,
});
});
When implementing custom rate-limited flows, similar test patterns should verify that the guard correctly identifies limit violations and that the "fails open" behavior functions during database outages.
Summary
- MessageRateGuard enforces per-recipient limits using a 10-minute rolling window with a default cap of 10 sends, as defined in
packages/schemas/src/consts/message-rate-limit.ts. - Recipients are normalized (emails lowercased, phone numbers parsed) and SHA-256 hashed to ensure consistent rate bucket identification across format variations.
- The
withMessageRateGuardwrapper integrates the guard, send operation, and activity recording into a single call, used across verification code and invitation endpoints. - Operators can override limits internally via the
messageRateLimitOverrideconfiguration key in thelogto_configstable, though this is not exposed to tenant APIs. - The system "fails open" during database query failures to prevent blocking legitimate traffic when infrastructure issues occur.
Frequently Asked Questions
What is the default rate limit for verification codes in Logto?
Logto applies a default policy of 10 sends per recipient within a 600-second (10-minute) rolling window. This configuration is hardcoded in packages/schemas/src/consts/message-rate-limit.ts and applies globally to all verification code and invitation sends unless overridden by an operator.
Can tenants configure their own rate limits through the Logto API?
No, the rate limiting policy is intentionally not tenant-configurable. Only Logto operators can apply partial overrides by inserting a messageRateLimitOverride record directly into the internal logto_configs table. This design ensures consistent protection across all tenants while preventing abuse of the rate limit mechanism itself.
What happens when a recipient hits the rate limit?
When a recipient exceeds the configured threshold, the guard method throws a RequestError with HTTP status code 429 and the error code request.message_rate_limited. The calling API endpoint catches this error and returns it to the client, indicating that the recipient must wait before requesting another code or invitation.
How does Logto handle rate limit checks if the database is temporarily unavailable?
The implementation uses a "fails open" strategy via the trySafe wrapper around database queries. If the query to count activities in the sentinel_activities table fails or returns undefined, the error is caught and suppressed, allowing the request to proceed. This prevents legitimate users from being blocked due to transient infrastructure issues.
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 →