API Security
Rate limiting, CORS configuration, input validation, CSRF protection, security headers, and replay attack prevention.
SCRIPE applies multiple layers of API security: rate limiting prevents abuse, CORS restricts cross-origin access, input validation rejects malformed data, security headers protect against common web attacks, and anti-replay mechanisms prevent request replay attacks.
Input Validation
Rate Limiting
SCRIPE implements 4-tier rate limiting using ASP.NET Core's built-in rate limiter: global DDoS protection, per-IP limits, per-endpoint limits, and authentication-specific limits for login and token refresh.
public static IServiceCollection AddRateLimitingConfiguration(
this IServiceCollection services, IConfiguration configuration)
{
var settings = configuration.GetSection("RateLimiting").Get<RateLimitingSettings>();
if (!settings.Enabled) return services;
services.AddRateLimiter(options =>
{
// 1. Global DDoS ceiling - 1000 requests per minute
options.AddFixedWindowLimiter("Global", opt =>
{
opt.PermitLimit = settings.GlobalLimit; // 1000
opt.Window = TimeSpan.FromMinutes(1);
opt.QueueLimit = 0;
});
// 2. Per-IP general ceiling - 200 requests per minute
options.AddPolicy("PerIp", context => {
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions {
PermitLimit = settings.PerIpLimit, // 200
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
});
});
// 3. Login / Auth endpoints brute-force shield - 10 attempts per 5 minutes per IP
options.AddPolicy("Login", context => {
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter($"login:{ip}", _ => new FixedWindowRateLimiterOptions {
PermitLimit = settings.LoginLimit, // 10
Window = TimeSpan.FromMinutes(5),
QueueLimit = 0
});
});
// 4. Token refresh rate limiting (prevent refresh farming) - 20 per minute sliding
options.AddPolicy("token-refresh", context => {
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetSlidingWindowLimiter($"refresh:{ip}", _ => new SlidingWindowRateLimiterOptions {
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 4,
QueueLimit = 0
});
});
});
return services;
}| Policy Name | Limitation Type | Default Threshold | Window/Period | Scope / Application Target |
|---|---|---|---|---|
| Global | Fixed Window | 1000 requests | 1 minute | Global server-wide protection (DDoS ceiling) |
| PerIp | Fixed Window | 200 requests | 1 minute | General unauthenticated API abuse prevention |
| Login | Fixed Window | 10 requests | 5 minutes | Brute-force protection on /login and /2fa/verify |
| token-refresh | Sliding Window | 20 requests | 1 minute | Refresh token endpoint (prevent refresh farming) |
| read-api | Sliding Window | 200 requests | 1 minute | GET endpoints (prevents client-side infinite loops) |
| mutation-api | Sliding Window | 30 requests | 1 minute | Mutating endpoints (POST/PUT/PATCH/DELETE) |
| per-user | Sliding Window | 100 requests | 1 minute | Total ceiling per authenticated user session |
| export-heavy | Fixed Window | 5 requests | 1 minute | CPU-heavy data export (CSV/Excel/PDF) endpoints |
| webhook | Sliding Window | 500 requests | 1 minute | Unauthenticated webhook endpoints (e.g. Stripe bursts) |
| signup | Fixed Window | 3 requests | 1 hour | Self-service registration endpoints (prevent signup spam) |
| phone-otp-send | Fixed Window | 3 requests | 15 minutes | SMS/OTP request endpoints (prevent SMS bombing) |
| password-reset | Fixed Window | 5 requests | 15 minutes | Password reset/OTP validation endpoints |
| passkey-auth | Fixed Window | 5 requests | 15 minutes | WebAuthn / passkey authentication attempts |
| qr-poll | Sliding Window | 60 requests | 1 minute | QR code authentication status polling endpoints |
CORS Configuration
Cross-Origin Resource Sharing policies differ between environments. Development allows all localhost origins. Production requires explicitly configured allowed origins, methods, and headers.
// Development: Allow local dev origins with credentials
policy.WithOrigins(
"http://localhost:3000", // Next.js dev
"https://localhost:3000", // Next.js secure dev
"http://localhost:3001"
)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials() // Cookies and SignalR hubs support
.WithExposedHeaders("X-SignalR-User-Agent", "X-CSRF-Token")
.SetPreflightMaxAge(TimeSpan.FromHours(1));CSRF Protection
SCRIPE uses the SameSite cookie attribute and anti-forgery tokens to prevent Cross-Site Request Forgery attacks. API endpoints rely on Bearer token authentication which is inherently CSRF-resistant.
/// <summary>
/// S0.15: Signed Double-Submit Cookie Pattern for CSRF protection.
///
/// Defense Layers:
/// 1. Sec-Fetch-Metadata + Origin Validation (blocks form-based navigate actions cross-site).
/// 2. Bearer token bypass: requests using custom Authorization headers bypass double-submit checks
/// because Bearer tokens are stored in JS memory and cannot be attached by browser cross-origin.
/// 3. HMAC-SHA256 Signed token: Bound to the JWT session's 'jti' claim to prevent token forgery.
/// 4. __Host- cookie prefix enforces Secure, Path=/, and blocks subdomain injection.
/// </summary>
public class CsrfMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
// Issue fresh token on every response (Response header + Cookie)
IssueSignedCsrfToken(context);
// Safe methods (GET, HEAD, OPTIONS) are exempt
if (SafeMethod(context.Request.Method) || PathExcluded(context.Request.Path))
{
await _next(context);
return;
}
// Layer 1: Fetch Metadata Validation
var fetchSite = context.Request.Headers["Sec-Fetch-Site"].FirstOrDefault();
var fetchMode = context.Request.Headers["Sec-Fetch-Mode"].FirstOrDefault();
if (fetchSite == "cross-site" && fetchMode == "navigate")
{
context.Response.StatusCode = 403; // Reject form post CSRF
return;
}
// Layer 2: Bearer Token bypass
if (context.Request.Headers.ContainsKey("Authorization"))
{
await _next(context);
return;
}
// Layer 3: Double-Submit Token verification
var cookieToken = context.Request.Cookies["__Host-XSRF-TOKEN"];
var headerToken = context.Request.Headers["X-CSRF-Token"].FirstOrDefault();
if (string.IsNullOrEmpty(cookieToken) || string.IsNullOrEmpty(headerToken))
{
context.Response.StatusCode = 403;
return;
}
// Constant-time check prevents timing side-channels
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(cookieToken),
Encoding.UTF8.GetBytes(headerToken)))
{
context.Response.StatusCode = 403;
return;
}
await _next(context);
}
}Replay Attack Prevention
Short-lived access tokens (15 minutes), single-use refresh tokens with rotation, and TOTP time-step validation prevent replay attacks across all authentication flows.
/// <summary>
/// P6.3: Request replay protection — MANDATORY on all authenticated mutations.
/// Requires X-Request-Timestamp (Unix epoch ms) and X-Request-Nonce (UUID format).
/// </summary>
public class ReplayProtectionMiddleware
{
public async Task InvokeAsync(HttpContext context, ICacheService cache)
{
// Skip safe methods (GET, HEAD, OPTIONS)
if (HttpMethods.IsGet(context.Request.Method))
{
await _next(context);
return;
}
// Skip SignalR hubs & OpenIddict auth endpoints
if (IsExcludedPath(context.Request.Path))
{
await _next(context);
return;
}
// Skip unauthenticated requests
if (context.User.Identity?.IsAuthenticated != true)
{
await _next(context);
return;
}
var timestampHeader = context.Request.Headers["X-Request-Timestamp"].FirstOrDefault();
var nonceHeader = context.Request.Headers["X-Request-Nonce"].FirstOrDefault();
// Reject missing headers immediately
if (string.IsNullOrEmpty(timestampHeader) || string.IsNullOrEmpty(nonceHeader))
{
context.Response.StatusCode = 400; // BAD_REQUEST
return;
}
// Nonce validation - max 36 chars, valid UUID
if (nonceHeader.Length > 36 || !Guid.TryParse(nonceHeader, out _))
{
context.Response.StatusCode = 400; // INVALID_NONCE
return;
}
// Timestamp validation - max 5 minutes drift (clock skew mitigation)
var requestTime = DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(timestampHeader));
var drift = (DateTimeOffset.UtcNow - requestTime).Duration();
if (drift > TimeSpan.FromMinutes(5))
{
context.Response.StatusCode = 400; // STALE_REQUEST
return;
}
// Nonce uniqueness check (cache TTL = 10 minutes)
var cacheKey = $"replay-nonce:{nonceHeader}";
var exists = await cache.GetAsync<string>(cacheKey);
if (exists is not null)
{
context.Response.StatusCode = 409; // REQUEST_REPLAY_DETECTED (Conflict)
return;
}
await cache.SetAsync(cacheKey, "1", TimeSpan.FromMinutes(10));
await _next(context);
}
}Security Headers
| Security Aspect | Value / Mechanism | Implementation details | Defense Objective |
|---|---|---|---|
| Access Token Lifetime | 15 minutes | Configured in Jwt:ExpiryMinutes. Short lifetime limits the window of opportunity for stolen tokens. | Mitigate token theft / session hijacking |
| Refresh Token Lifetime | 7 days | Configured in Jwt:RefreshExpiryDays. Single-use rotated refresh tokens mapped in database. | Maintain active sessions securely |
| Refresh Token Hashing | SHA-256 One-Way Hash | Refresh tokens are hashed prior to database persistence. Plaintext tokens never stored. | Protect database secrets from breach leak |
| PKCE Client Validation | S256 Challenge Method | OidcClientService generates random 32-byte code_verifier and SHA-256 code_challenge. | Prevent authorization code interception attacks |
| PKCE Server Enforcement | OpenIddict Global Rule | OpenIddict is configured to globally require PKCE for all authorization code flows. | Enforce modern OAuth 2.1 security standards |
Security Headers
Production responses include security headers: X-Content-Type-Options (nosniff), X-Frame-Options (DENY), X-XSS-Protection, Referrer-Policy, and Content-Security-Policy.
| Header | Value | Purpose |
|---|---|---|
| Strict-Transport-Security | max-age=31536000; includeSubDomains | Force HTTPS for 1 year |
| X-Content-Type-Options | nosniff | Prevent MIME-type sniffing |
| X-Frame-Options | DENY | Prevent clickjacking |
| X-XSS-Protection | 1; mode=block | Legacy XSS filter |
| Content-Security-Policy | default-src 'self'; script-src 'self' | Restrict resource origins |
| Referrer-Policy | strict-origin-when-cross-origin | Control referrer leakage |
| Permissions-Policy | camera=(), microphone=(), geolocation=() | Disable unused browser features |
| Cache-Control | no-store, no-cache, must-revalidate | Prevent sensitive data caching |