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

Mutating Request Security Verification Pipeline
Mutating Request (POST/PUT/PATCH/DELETE)
Rate Limiter — Per-IP, Global, or Endpoint policy
CSRF Middleware — Fetch site/mode metadata validation
HMAC Signed Token Check — FixedTimeEquals double-submit validation
Authentication (JWT) — Validate access token & resolve claims
Replay Protection — Timestamp ±5m & Nonce unique (10m cache)
Authorization (RBAC) — Verify user permissions & scopes
Input Sanitization — InputSanitizationMiddleware HTML strip
Controller Action — Execute business logic

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.

RateLimitingConfiguration.csC#
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 NameLimitation TypeDefault ThresholdWindow/PeriodScope / Application Target
GlobalFixed Window1000 requests1 minuteGlobal server-wide protection (DDoS ceiling)
PerIpFixed Window200 requests1 minuteGeneral unauthenticated API abuse prevention
LoginFixed Window10 requests5 minutesBrute-force protection on /login and /2fa/verify
token-refreshSliding Window20 requests1 minuteRefresh token endpoint (prevent refresh farming)
read-apiSliding Window200 requests1 minuteGET endpoints (prevents client-side infinite loops)
mutation-apiSliding Window30 requests1 minuteMutating endpoints (POST/PUT/PATCH/DELETE)
per-userSliding Window100 requests1 minuteTotal ceiling per authenticated user session
export-heavyFixed Window5 requests1 minuteCPU-heavy data export (CSV/Excel/PDF) endpoints
webhookSliding Window500 requests1 minuteUnauthenticated webhook endpoints (e.g. Stripe bursts)
signupFixed Window3 requests1 hourSelf-service registration endpoints (prevent signup spam)
phone-otp-sendFixed Window3 requests15 minutesSMS/OTP request endpoints (prevent SMS bombing)
password-resetFixed Window5 requests15 minutesPassword reset/OTP validation endpoints
passkey-authFixed Window5 requests15 minutesWebAuthn / passkey authentication attempts
qr-pollSliding Window60 requests1 minuteQR 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.

CORS — Development ConfigurationC#
// 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.

CsrfMiddleware.csC#
/// <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.

ReplayProtectionMiddleware.csC#
/// <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 AspectValue / MechanismImplementation detailsDefense Objective
Access Token Lifetime15 minutesConfigured in Jwt:ExpiryMinutes. Short lifetime limits the window of opportunity for stolen tokens.Mitigate token theft / session hijacking
Refresh Token Lifetime7 daysConfigured in Jwt:RefreshExpiryDays. Single-use rotated refresh tokens mapped in database.Maintain active sessions securely
Refresh Token HashingSHA-256 One-Way HashRefresh tokens are hashed prior to database persistence. Plaintext tokens never stored.Protect database secrets from breach leak
PKCE Client ValidationS256 Challenge MethodOidcClientService generates random 32-byte code_verifier and SHA-256 code_challenge.Prevent authorization code interception attacks
PKCE Server EnforcementOpenIddict Global RuleOpenIddict 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.

HeaderValuePurpose
Strict-Transport-Securitymax-age=31536000; includeSubDomainsForce HTTPS for 1 year
X-Content-Type-OptionsnosniffPrevent MIME-type sniffing
X-Frame-OptionsDENYPrevent clickjacking
X-XSS-Protection1; mode=blockLegacy XSS filter
Content-Security-Policydefault-src 'self'; script-src 'self'Restrict resource origins
Referrer-Policystrict-origin-when-cross-originControl referrer leakage
Permissions-Policycamera=(), microphone=(), geolocation=()Disable unused browser features
Cache-Controlno-store, no-cache, must-revalidatePrevent sensitive data caching