# Maybe Finance API Security: API Key Authentication and Rate Limiting Explained

> Secure your integration with Maybe Finance API. Learn about robust API key authentication, OAuth fallback, and advanced rate limiting techniques to protect your data. Explore our security features.

- Repository: [Maybe/maybe](https://github.com/maybe-finance/maybe)
- Tags: deep-dive
- Published: 2026-03-07

---

**Maybe Finance secures its JSON API through encrypted API key storage, OAuth fallback authentication, and multi-layered rate limiting using Redis-backed sliding windows with tiered limits.**

The open-source maybe-finance/maybe repository implements a defense-in-depth strategy for API security. The system combines deterministic encryption for key storage, dual authentication methods, and multiple rate limiting layers to protect both hosted and self-hosted deployments.

## API Key Storage and Encryption

The `ApiKey` model in [`app/models/api_key.rb`](https://github.com/maybe-finance/maybe/blob/main/app/models/api_key.rb) handles secure storage and validation of API credentials. Keys are never stored in plaintext; instead, the system uses Rails Active Record encryption with deterministic encryption enabled for fast lookup capabilities.

### Deterministic Encryption for Fast Lookup

The model encrypts the 64-character secret key using `deterministic: true`, which allows the system to query keys by their encrypted value without exposing the plaintext. The `find_by_value` method performs this lookup efficiently:

```ruby

# From app/models/api_key.rb lines 24-32

def self.find_by_value(value)
  find_by(value: value)
rescue ActiveRecord::Encryption::Errors::Decryption
  nil
end

```

### Validation and Scope Enforcement

Each API key enforces a single active key per user and source combination (web or mobile), preventing key sprawl. The model validates scopes strictly, supporting only `read` or `read_write` permissions:

```ruby

# Validation logic from app/models/api_key.rb lines 10-16

validates :scopes, presence: true, inclusion: { in: %w[read read_write] }
validates :source, presence: true, inclusion: { in: %w[web mobile] }
validate :single_active_key_per_user_and_source, on: :create

```

## Authentication Flow in the API Controller

The `Api::V1::BaseController` in [`app/controllers/api/v1/base_controller.rb`](https://github.com/maybe-finance/maybe/blob/main/app/controllers/api/v1/base_controller.rb) orchestrates the authentication pipeline. It attempts OAuth token authentication first, then falls back to API key extraction from the `X-Api-Key` header.

### OAuth Token Fallback

The controller first checks for Bearer tokens in the Authorization header. If present, it validates the token against the database and populates the user context:

```ruby

# OAuth flow from app/controllers/api/v1/base_controller.rb lines 48-84

def authenticate_with_oauth_token
  # Extracts Bearer token and validates against OAuthAccessToken

  # Sets @current_user on success

end

```

### API Key Extraction and Validation

When OAuth fails or is absent, the controller extracts the `X-Api-Key` header and validates it against the encrypted store. Successful authentication updates the `last_used_at` timestamp and initializes the rate limiter:

```ruby

# API key flow from app/controllers/api/v1/base_controller.rb lines 90-104

def authenticate_with_api_key
  api_key = request.headers["X-Api-Key"]
  @current_api_key = ApiKey.find_by_value(api_key)
  @current_api_key.touch(:last_used_at) if @current_api_key
  # Rate limiter initialization follows

end

```

### User Context and Audit Logging

Every authenticated request triggers comprehensive logging in [`app/controllers/api/v1/base_controller.rb`](https://github.com/maybe-finance/maybe/blob/main/app/controllers/api/v1/base_controller.rb) lines 218-232. The system records the HTTP method, request path, user email, family ID, and authentication method (OAuth vs API key), creating an audit trail for security monitoring.

## Rate Limiting Architecture

The `ApiRateLimiter` service in [`app/services/api_rate_limiter.rb`](https://github.com/maybe-finance/maybe/blob/main/app/services/api_rate_limiter.rb) implements per-key rate limiting using Redis-backed sliding windows. This design allows horizontal scaling while maintaining strict request quotas.

### Redis-Backed Sliding Windows

The limiter stores counters in Redis with TTL-based expiration, creating automatic sliding windows. Each API key has independent counters, preventing one user from consuming another's quota:

```ruby

# Core logic from app/services/api_rate_limiter.rb lines 16-50

def increment!
  # Atomically increments Redis counter with expiration

end

def exceeded?
  # Compares current count against tier limit

end

```

### Tiered Limit Structure

Maybe implements three rate limit tiers defined in [`app/services/api_rate_limiter.rb`](https://github.com/maybe-finance/maybe/blob/main/app/services/api_rate_limiter.rb) lines 4-7:

- **Standard**: 100 requests per hour
- **Premium**: 1,000 requests per hour  
- **Enterprise**: 10,000 requests per hour

The tier is determined by the user's subscription status and passed to the limiter during initialization.

### Self-Hosted Noop Mode

For self-hosted installations, the `NoopApiRateLimiter` class disables throttling entirely. This allows private deployments to operate without Redis infrastructure or request constraints, configured via environment variables.

## Controller-Level Rate Limit Enforcement

After authentication, the `Api::V1::BaseController` enforces rate limits through the `check_api_key_rate_limit` callback.

### 429 Response Handling

When limits are exceeded, the controller returns a structured JSON error with HTTP 429 status. The response includes detailed metadata about the violation:

```ruby

# Limit exceeded response from app/controllers/api/v1/base_controller.rb lines 124-141

def render_rate_limit_exceeded
  render json: {
    error: "rate_limit_exceeded",
    message: "API rate limit exceeded",
    details: {
      limit: @rate_limiter.limit,
      remaining: 0,
      reset_in_seconds: @rate_limiter.reset_in_seconds
    }
  }, status: :too_many_requests
end

```

### Rate Limit Headers

Successful responses include standard `X-RateLimit-*` headers to inform clients of their current quota status. The `add_rate_limit_headers` method in [`app/controllers/api/v1/base_controller.rb`](https://github.com/maybe-finance/maybe/blob/main/app/controllers/api/v1/base_controller.rb) lines 144-148 injects:

- `X-RateLimit-Limit`: Total requests allowed per window
- `X-RateLimit-Remaining`: Requests remaining in current window  
- `X-RateLimit-Reset`: Unix timestamp when the window resets

## Global HTTP Throttling with Rack Attack

Maybe employs Rack::Attack as a secondary defense layer in [`config/initializers/rack_attack.rb`](https://github.com/maybe-finance/maybe/blob/main/config/initializers/rack_attack.rb). This middleware operates before Rails routing, providing IP-level and token-level protection.

The configuration implements two primary throttles:

1. **OAuth Token Endpoint Protection**: Limits requests to `/oauth/token` to prevent brute-force attacks on authentication
2. **Generic API Throttling**: Caps requests per IP address and per OAuth bearer token across all `/api/` routes

For development and testing environments, the initializer includes permissive IP throttling that prevents local development from hitting limits while maintaining the production code paths.

## Practical Code Examples

### Creating and Using an API Key

Generate a new key through the Rails console:

```ruby

# Generate a new key for the current user (web source)

key = current_user.api_keys.create!(
  name: "My script",
  scopes: ["read_write"],
  source: "web"
)

puts "Save this once – it will never be shown again:"
puts key.plain_key   # => e.g. "a3f9c2d4…"

```

### Authenticating API Requests

Include the key in the `X-Api-Key` header:

```bash
curl -H "X-Api-Key: a3f9c2d4…" \
     https://api.maybe.com/api/v1/accounts

```

The response includes rate limit headers:

```

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 97
X-RateLimit-Reset: 3540

```

### Handling Rate Limit Exceeded (429) Responses

Implement exponential backoff when receiving HTTP 429:

```ruby
#!/usr/bin/env ruby
require 'net/http'
require 'json'

url = URI('https://api.maybe.com/api/v1/transactions')
key = ENV['MAYBE_API_KEY']

loop do
  resp = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http|
    req = Net::HTTP::Get.new(url)
    req['X-Api-Key'] = key
    http.request(req)
  end

  if resp.code == '429'
    body = JSON.parse(resp.body)
    wait = resp['Retry-After'] || body['details']['reset_in_seconds'] || 60
    puts "Rate limited – sleeping #{wait}s"
    sleep(wait.to_i)
  else
    puts "Success #{resp.code}"
    break
  end
end

```

### Revoking API Keys

Invalidate a key immediately through the console:

```ruby
key = current_user.api_keys.find_by(name: "My script")
key.revoke!    # sets revoked_at, making it instantly invalid

```

All subsequent requests with that key will receive a **401 Unauthorized** response.

## Summary

- **Encrypted Storage**: API keys use Rails Active Record deterministic encryption in [`app/models/api_key.rb`](https://github.com/maybe-finance/maybe/blob/main/app/models/api_key.rb), enabling secure storage with fast lookup capabilities.
- **Dual Authentication**: The `Api::V1::BaseController` supports both OAuth tokens and API keys via the `X-Api-Key` header, with comprehensive audit logging.
- **Tiered Rate Limiting**: The `ApiRateLimiter` service implements sliding window counters in Redis with standard (100/hr), premium (1,000/hr), and enterprise (10,000/hr) tiers.
- **Graceful Degradation**: Rate limit responses include structured JSON errors, `Retry-After` headers, and standard `X-RateLimit-*` headers for client-side backoff handling.
- **Defense in Depth**: Rack::Attack provides IP-level and token-level throttling at the middleware layer, protecting against brute-force attacks on the OAuth token endpoint.

## Frequently Asked Questions

### How are API keys stored securely in Maybe Finance?

API keys are encrypted at rest using Rails Active Record encryption with the `deterministic: true` option, as implemented in [`app/models/api_key.rb`](https://github.com/maybe-finance/maybe/blob/main/app/models/api_key.rb). This approach ensures that the 64-character secret is never stored in plaintext, while still allowing the system to look up keys by their encrypted value using the `find_by_value` method.

### What happens when I exceed the API rate limit?

When you exceed your tier's request quota (100 requests per hour for standard accounts), the API returns an HTTP 429 status code with a JSON error body containing the limit details and reset time. The response includes `Retry-After` and `X-RateLimit-Reset` headers that indicate when you can resume making requests, allowing your client to implement automatic backoff strategies.

### Can I disable rate limiting for my self-hosted Maybe instance?

Yes, self-hosted installations can disable API rate limiting entirely by using the `NoopApiRateLimiter` class instead of the standard Redis-backed limiter. This configuration bypasses all request throttling, allowing private deployments to operate without Redis infrastructure or request constraints, which is useful for internal integrations or development environments.

### How does Maybe prevent brute-force attacks against API authentication?

Maybe implements defense-in-depth through Rack::Attack middleware configured in [`config/initializers/rack_attack.rb`](https://github.com/maybe-finance/maybe/blob/main/config/initializers/rack_attack.rb). This adds IP-level throttling for the OAuth token endpoint (`/oauth/token`) and generic API routes, limiting requests per IP address and per OAuth bearer token before they reach the application layer, effectively blocking brute-force attempts against authentication endpoints.