# Setting up Rate Limiting for API Protection with Logto: Implementation Guide

> Implement API protection with Logto's rate limiting. Discover how to set up the MessageRateGuard sentinel for secure outbound messaging and prevent abuse.

- Repository: [Logto/logto](https://github.com/logto-io/logto)
- Tags: how-to-guide
- Published: 2026-07-04

---

**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`](https://github.com/logto-io/logto/blob/main/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`](https://github.com/logto-io/logto/blob/main/packages/schemas/src/consts/message-rate-limit.ts):

```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`](https://github.com/logto-io/logto/blob/main/packages/core/src/sentinel/message-rate-guard.ts) handles this transformation:

```ts
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:

```ts
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:

```ts
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:

```ts
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-code`** in [`packages/core/src/routes/verification/index.ts`](https://github.com/logto-io/logto/blob/main/packages/core/src/routes/verification/index.ts) guards all verification code requests sent via the admin API.
- **`POST /api/verification-code`** (user self-service) in [`packages/core/src/routes-me/verification-code.ts`](https://github.com/logto-io/logto/blob/main/packages/core/src/routes-me/verification-code.ts) applies the same protection to end-user initiated requests.
- **Organization invitations** in [`packages/core/src/libraries/organization-invitation.ts`](https://github.com/logto-io/logto/blob/main/packages/core/src/libraries/organization-invitation.ts) wrap the email or SMS send operation to prevent invitation spam.
- **Experience API verification** in [`packages/core/src/routes/experience/verification-routes/verification-code-helpers.ts`](https://github.com/logto-io/logto/blob/main/packages/core/src/routes/experience/verification-routes/verification-code-helpers.ts) protects 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:

```sql
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`](https://github.com/logto-io/logto/blob/main/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:

```ts
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`](https://github.com/logto-io/logto/blob/main/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 `withMessageRateGuard` wrapper 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 `messageRateLimitOverride` configuration key in the `logto_configs` table, 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`](https://github.com/logto-io/logto/blob/main/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.