Maybe Finance API Security: API Key Authentication and Rate Limiting Explained
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 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:
# 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:
# 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 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:
# 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:
# 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 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 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:
# 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 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:
# 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 lines 144-148 injects:
X-RateLimit-Limit: Total requests allowed per windowX-RateLimit-Remaining: Requests remaining in current windowX-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. This middleware operates before Rails routing, providing IP-level and token-level protection.
The configuration implements two primary throttles:
- OAuth Token Endpoint Protection: Limits requests to
/oauth/tokento prevent brute-force attacks on authentication - 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:
# 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:
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:
#!/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:
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, enabling secure storage with fast lookup capabilities. - Dual Authentication: The
Api::V1::BaseControllersupports both OAuth tokens and API keys via theX-Api-Keyheader, with comprehensive audit logging. - Tiered Rate Limiting: The
ApiRateLimiterservice 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-Afterheaders, and standardX-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. 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. 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.
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 →