# Openship Rate-Limiter Middleware: Complete Guide to API Endpoint Protection

> Secure your API endpoints with Openship rate-limiter middleware. This guide details how to protect your public API using named policies for IP, user, or organization identifiers.

- Repository: [oblien/openship](https://github.com/oblien/openship)
- Tags: how-to-guide
- Published: 2026-08-19

---

**Openship protects its public API with a centralized rate-limiting layer that assigns named policies to requests based on IP, user, or organization identifiers, returning HTTP 429 with standard `Retry-After` headers when limits are exceeded.**

The Openship rate-limiter middleware provides enterprise-grade API endpoint protection for the open-source logistics platform. Implemented in TypeScript within the `apps/api` package, this system sits in the Hono request-handling stack to prevent brute-force attacks and resource exhaustion. Whether you are operating a SaaS deployment or extending the platform, understanding this architecture allows you to configure appropriate throttling boundaries.

## How the Rate-Limiter Middleware Works

The implementation lives in [`apps/api/src/middleware/rate-limiter.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/middleware/rate-limiter.ts) and operates through two distinct entry points that enforce policies across the routing tree.

### Two Entry Points for Policy Enforcement

**`rateLimiterFor(policyId)`** returns a per-route Hono middleware bound to a specific named policy. Use this for isolated endpoints that bypass the secure router.

**`globalAnonLimiter`** (exported as `rateLimiter`) mounts on the entire `/api` tree. It enforces either the `default-anon` or `default-authed` policy when no route-specific override exists.

When a route specifies a custom limit through `secureRouter`, the system sets `c.set("rateLimitPolicy", …)` upstream. The global limiter detects this via `c.get("rateLimitApplied")` and skips its own default enforcement to avoid double-counting.

### Subject Resolution Strategy

The helper `resolveSubjectId` extracts the identifier used for bucket tracking based on the policy configuration:

- **`"ip"`** – Client IP from `c.var.clientIp` (or loopback in development).
- **`"user"` / `"org"`** – Pulled from `getRequestContext` for authenticated requests.
- **`"global"`** – Static string `"global"` for cluster-wide caps.

### Health Endpoint Exemptions and Dev Bypass

Requests to `/api/health/*` are never rate-limited (see lines 34–37 in [`rate-limiter.ts`](https://github.com/oblien/openship/blob/main/rate-limiter.ts)), ensuring dashboard health checks cannot trigger cascade failures.

During local development, when `env.TRUST_PROXY` is false and the connection originates from the loopback interface, requests bypass ordinary limits (except the auth gate). This prevents developer workstations from exhausting per-IP buckets during testing.

## Rate-Limit Policy Catalog

Policies are defined in [`apps/api/src/lib/rate-limit/policies.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/lib/rate-limit/policies.ts) as named buckets with specific limits, windows, and subject types. Extend the `PolicyId` union and update the `POLICIES` map to add custom rules.

| Policy | Limit (req/min) | Subject | Use Case |
|--------|----------------|---------|----------|
| `default-anon` | 300 | IP | Catch-all for public routes |
| `default-authed` | 3000 | User | Default for authenticated routes |
| `auth-tight` | 10 | IP | Login/signup brute-force protection |
| `auth-loose` | 60 | User | Post-authentication actions |
| `mcp` | 300 | IP | JSON-RPC tooling endpoints |
| `read-authed` | 600 | User | Read-only API calls |
| `write-authed` | 300 | User | Write operations (POST/PUT/PATCH/DELETE) |
| `webhook-ingress` | 120 | IP | Inbound webhook deliveries |
| `billing-portal` | 20 | Org | Stripe portal creation |

## Backend Storage: Redis vs In-Memory

The `pickBackend` function in [`apps/api/src/lib/rate-limit/index.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/lib/rate-limit/index.ts) (lines 35–44) selects the storage implementation at first use. Set `OPENSHIP_RATE_LIMIT_STORE=memory|redis` to override the automatic selection.

- **Redis store** ([`redis-store.ts`](https://github.com/oblien/openship/blob/main/redis-store.ts)): Recommended for production SaaS deployments. Uses atomic Redis commands for distributed counter consistency.
- **Memory store** ([`memory-store.ts`](https://github.com/oblien/openship/blob/main/memory-store.ts)): Fallback for single-instance installations or local development. Maintains counters in-process.

## Implementing API Endpoint Protection

### Protecting Routes with secureRouter

When using the secure router abstraction, declare rate limits in the route specification:

```typescript
secureRouter.post(
  "/auth/sign-in",
  { public: true, rateLimit: "auth-tight" },
  authController.signIn,
);

```

### Attaching Middleware Directly

For endpoints that bypass `secureRouter`, import `rateLimiterFor` and apply it directly:

```typescript
import { rateLimiterFor } from "../../middleware/rate-limiter";

router.patch(
  "/servers/:id/rate-limit",
  rateLimiterFor("auth-tight"),
  serverRateLimitController.update,
);

```

### Manual Rate-Limit Checks

Invoke the limiter programmatically in custom services using `rateLimit` from [`apps/api/src/lib/rate-limit/index.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/lib/rate-limit/index.ts):

```typescript
import { rateLimit } from "../lib/rate-limit";
import { getPolicy } from "../lib/rate-limit/policies";

async function checkUserCanExport(userId: string) {
  const policy = getPolicy("write-authed");
  const { allowed, remaining, resetMs } = await rateLimit({
    policy: "write-authed",
    subjectId: userId,
  });

  if (!allowed) {
    throw new Error(
      `Rate limit exceeded – ${remaining} left, resets in ${resetMs / 1000}s`,
    );
  }
}

```

## Extending the System: Adding Custom Policies

To create a new rate-limit tier, modify [`apps/api/src/lib/rate-limit/policies.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/lib/rate-limit/policies.ts):

```typescript
// 1️⃣ Extend the PolicyId union
export type PolicyId =
  | "default-anon"
  | "default-authed"
  // …existing IDs…
  | "my-custom-policy";

// 2️⃣ Add the policy definition
export const POLICIES: Record<PolicyId, RateLimitPolicy> = {
  // …existing entries…
  "my-custom-policy": {
    id: "my-custom-policy",
    limit: 50,
    windowMs: 60_000,
    subject: "ip",
    description: "Custom policy – 50 req/min per IP.",
  },
};

```

Then apply it using `rateLimiterFor("my-custom-policy")` in your route definition.

## Key Implementation Files

| File | Role |
|------|------|
| [`apps/api/src/middleware/rate-limiter.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/middleware/rate-limiter.ts) | Core middleware mapping policies to subjects and returning 429 responses |
| [`apps/api/src/lib/rate-limit/index.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/lib/rate-limit/index.ts) | Facade selecting Redis/memory backends and providing graceful shutdown |
| [`apps/api/src/lib/rate-limit/policies.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/lib/rate-limit/policies.ts) | Catalog of named policies and metadata |
| [`apps/api/src/lib/rate-limit/memory-store.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/lib/rate-limit/memory-store.ts) | In-process storage for development |
| [`apps/api/src/lib/rate-limit/redis-store.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/lib/rate-limit/redis-store.ts) | Production-grade Redis atomic operations |
| [`apps/api/src/modules/system/system.routes.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/modules/system/system.routes.ts) | Example integration showing middleware wiring |
| [`apps/api/src/modules/system/rate-limit.controller.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/modules/system/rate-limit.controller.ts) | Controller for reading/updating per-server limits |
| [`apps/api/src/lib/request-context.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/lib/request-context.ts) | Utility supplying `userId` and `organizationId` for subject resolution |

## Summary

- Openship implements **API endpoint protection** through a centralized middleware layer in `apps/api` that supports both Redis and in-memory backends.
- **Policy enforcement** relies on two entry points: `rateLimiterFor()` for specific routes and a global limiter for catch-all protection.
- **Subject resolution** maps requests to buckets by IP, user, organization, or global cluster identifiers.
- **Health endpoints** are automatically exempt, and **loopback development traffic** bypasses limits to prevent local testing friction.
- **HTTP 429 responses** include standard `Retry-After` and `X-RateLimit-*` headers when the `enforce` function detects exceeded limits.

## Frequently Asked Questions

### How does Openship handle rate limiting in development environments?

When `env.TRUST_PROXY` is false and requests originate from the loopback interface, the middleware in [`rate-limiter.ts`](https://github.com/oblien/openship/blob/main/rate-limiter.ts) bypasses standard limits to prevent developer workstations from exhausting buckets during local testing. The system still enforces authentication gates but ignores per-IP counters for localhost traffic.

### Can I use Redis clustering for the rate-limit backend?

Yes. The [`redis-store.ts`](https://github.com/oblien/openship/blob/main/redis-store.ts) implementation supports production SaaS deployments using Redis atomic commands. Configure the connection via environment variables, and the `pickBackend` function in [`rate-limit/index.ts`](https://github.com/oblien/openship/blob/main/rate-limit/index.ts) will automatically select the Redis store when `OPENSHIP_RATE_LIMIT_STORE=redis` is set or when Redis credentials are detected.

### What headers does Openship return when rate limits are exceeded?

When a request exceeds the policy limit, the middleware returns **HTTP 429 Too Many Requests** with `Retry-After` indicating seconds until reset, plus `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. These follow standard conventions for API rate-limit communication.

### How do I exempt specific endpoints from rate limiting?

Add the endpoint pattern to the exemption check in [`apps/api/src/middleware/rate-limiter.ts`](https://github.com/oblien/openship/blob/main/apps/api/src/middleware/rate-limiter.ts) (lines 34–37) alongside the existing `/api/health/*` check. Alternatively, use the `secureRouter` configuration without a `rateLimit` key to inherit the default policy, or bypass the global limiter entirely by not applying `rateLimiterFor` to the route.