AI Products
AI LabsOpenAI AgentsClaude AgentsGrok BotWorkstation CRM (WSL CRM)MarketingAll Products
AI Solutions
Applied AIAI AgentsGenerative AIAI AutomationAI UpskillingAI SME PackagesEnterprise AI LabAI WorkstationsPrivate AIGPU ClustersEdge AIAI by Industry
AI Services
Platform ModernisationDigital EngineeringData Foundations & AIAutonomous OperationsAI ConsultancyDevOps AutomationCyber SecuritySoftware DevelopmentAgent BuildingMLOps Setup
About Us
PartnersCustomer Stories
AI InsightsJev seriesBenchmark LabModel selection
Articles
Documentation
ToolsWSL ProxyRing PromoterWSL VaultJobshoutSysOps 24/7
News
Blog
Contact UsLogin

AI workstations, AI Multi Agentic Software, GPU infrastructure, and intelligent agent solutions for modern businesses.

Contact Us

AI Solutions

Applied AIAI AgentsGenerative AIAI AutomationAI UpskillingAI SME PackagesEnterprise AI LabAI by Industry

Products

All ProductsWSL CRM & ERPMarketingOpenAI AgentsWSL ProxyRing PromoterWSL VaultJobshoutSysOps 24/7

Company

About UsWhy WorkstationPartnersCustomer StoriesPricingContact

Resources

AI InsightsArticlesDocumentationBlogSearchSitemap
UK Office
77-79 Marlowes, Hemel Hempstead HP1 1LFDirections - Take Junction 20 off M25 Outer LondonCompany No: 11641870Mon - Fri: 9:00 AM - 6:00 PM GMT
+44 7515 356 146
Belgium Office
Workstation SRL, Rue Vanderkindere 34, 1180 Uccle, BrusselsBE 0751.518.683Mon - Fri: 9:00 AM - 6:00 PM CET
+32 492 45 67 46
India Office
#159 Sector 9, Pocket 1, DDA Flats, 110077 Dwarka, New Delhi
+91 98881 98841

© 2026 Workstation AI. All rights reserved.

PrivacyCookiesTerms of ServiceWebsite Sitemap
Home / Articles / Technology
SecurityDevOpsOpen Source

How We Protected the Payments Secure FinTech App with WSLProxy Edge, an Open-Source API Gateway

The engineering write-up: threat model, the api_gw pipeline, every setting we used, the CORS incident we fixed in three minutes, and a checklist for your own API

September 28, 2026Technology15 min read

This is the engineering write-up. For a plain-English version, read the companion blog post.

How we protected the Payments Secure fintech app with WSLProxy Edge

In brief. We put the Payments Secure API behind the api_gw pipeline of WSLProxy Edge, our open-source gateway. It gives us Kong-class edge controls without running Kong: trusted real-IP resolution, correlation IDs, invalid-traffic (IVT) detection, Content-Type and body-size enforcement, route-aware sliding-window rate limits, and a redacted audit line per request. The whole policy is one JSON block in git, deployed by the same register workflow as our routing rules. We rolled it out to the integration API in audit mode first. One login regression surfaced within minutes and was fixed three minutes after that. This article covers the design, the exact settings, the incident and what we would do the same way again.

1. The app and the threat model

Payments Secure (payments-secure.fictionally.org) is a fictionalised fintech case study: customers authenticate, upload supporting documents, and call payment and account APIs. Behind the web front end sit two services:

  • OpsAPI, a Lapis/OpenResty service for accounts, authentication (password and Google OAuth), businesses and payment accounts, mounted under /opsapi.
  • FastAPI, a Python service for document ingestion, extraction and payment-related processing, mounted under /fastapi. Document uploads land on /fastapi/api/v1/statements.

Both are published through one hostname per environment. This article uses the integration API, int-api.payments-secure.fictionally.org, which our team and testers use daily and which runs the same edge software as production.

The threats we cared about are the ordinary ones any public API faces. They matter more here because of the data involved:

  • Opportunistic scanning. Bots probing for /.env, /.git/, /wp-admin and *.php. None of these exist on our stack, so any hit is by definition hostile or broken.
  • Credential stuffing and password spraying against the login endpoints.
  • Header spoofing. Requests that carry X-Internal-*, X-Consumer-* or X-Authenticated-* headers, hoping a backend trusts them as if an upstream component had set them.
  • IP spoofing via X-Forwarded-For. If the edge trusts the header from anyone, every per-IP control becomes optional for the attacker.
  • Resource exhaustion. Oversized bodies, unusual methods (TRACE, TRACK, CONNECT) and bursts that tie up workers doing expensive extraction.
  • Poor forensics. Without a shared request ID, a customer complaint cannot be joined to edge, OpsAPI and FastAPI logs.

2. Why the edge and not the application

We could have added middleware to each service. We chose not to, for three reasons:

  1. Two languages, one policy. OpsAPI is Lua and FastAPI is Python. The same rules written twice will drift apart.
  2. Reject early. A request rejected at the edge costs one table lookup and a small response. The same request rejected in FastAPI has already used a worker, parsed a body and maybe opened a database connection.
  3. The edge already exists. WSLProxy was already terminating traffic for the domain, routing to the cluster and running our WAF. A dedicated gateway such as Kong would have added another hop, another deployment, another database and another thing to patch.

WSLProxy's api_gw package was built for exactly this. A tenant adds an api_gw block to its server record and the next request goes through the pipeline. There is no nginx reload, no per-app gateway deployment and no Lua to write. A server with no api_gw block behaves exactly as before.

3. Where the gateway sits

Traffic arrives through a CDN, then reaches the WSLProxy edge node in London, which proxies to the Kubernetes cluster that runs the app. Inside WSLProxy the request path is:

gateway_ack.lua        (rewrite phase)
  cache → captcha-verify → load server → match rules → gateway_pipeline.execute:
      1. api_gw     ← the pipeline described here
      2. rate_limit (legacy server-level limiter)
      3. WAF        (waf_engine.lua)
gateway_resp.lua       (access phase)   status codes, backend selection
header_filter          ApiGw.header_filter()  correlation / RateLimit-* headers
log                    ApiGw.log()            structured audit line

Within api_gw the stages run in a fixed order. The order is declared once in api/api_gw/pipeline.lua, and the docs and tests read it from the same source. The first stage to return a decision ends the pipeline. A request rejected by IVT therefore never reaches the auth verifier, and a CORS preflight never uses up rate-limit quota.

WSLProxy Edge api_gw pipeline: real IP, correlation, IVT guard, request hygiene, rate limit and audit in front of the Payments Secure API

PriorityStageWhy it sits there
1000real_ipEverything downstream keys on the client IP
995hooks_requestDeclarative header transforms and optional Lua hooks
980correlationSet before anything can deny, so every rejection carries an ID
950corsPreflights terminate here, unauthenticated and unmetered
900ivtCheap structural checks before parsing or verifying anything
850request_securityContent-Type, body size and token shape on traffic judged real
800authOptional credential verification (none, passthrough, jwt, api_key)
700rate_limitLast, so a quota can key on a verified consumer

4. The configuration, module by module

Everything below lives in one file in the app repository, .github/wslproxy/data/servers/prod/host:int-api.payments-secure.fictionally.org.json, under the api_gw key. The enabled modules are:

"api_gw": {
  "enabled": true,
  "tenant_id": "payments-secure-int-api",
  "modules": ["real_ip", "request_security", "ivt", "rate_limit", "audit"],
  ...
}

An explicit tenant_id gives the environment its own counters and audit identity. By default the tenant is <profile>/<server_name>, so the same hostname in two environments never shares a quota.

4.1 real_ip: believe forwarded headers only from trusted hops

"real_ip": {
  "trusted_cidrs": ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
                    "<the CDN's published edge ranges>"],
  "recursive": true
}

The client IP is taken from the forwarded header only when the directly connected peer falls inside a trusted range. recursive walks back through the chain of trusted hops to the first untrusted address. That address is the real client. A request sent straight to the origin with a forged X-Forwarded-For is judged by its actual socket address. Without this stage, every per-IP limit further down could be bypassed with one header.

4.2 request_security: correlation, Content-Type and body size

"request_security": {
  "correlation":  { "enabled": true, "header": "X-Correlation-ID" },
  "content_type": {
    "enforce": true,
    "methods": ["POST", "PUT", "PATCH"],
    "allow": ["application/json", "multipart/form-data",
              "application/x-www-form-urlencoded"],
    "exempt_paths": ["/health", "/ready", "/opsapi/health", "/fastapi/health"]
  },
  "max_body_bytes": 10485760
}
  • Correlation. An incoming X-Correlation-ID is kept. If there is none, one is minted and forwarded to OpsAPI and FastAPI. The ID is also returned to the browser and written to the audit line, so a support ticket can be traced across the edge and both services.
  • Content-Type. Writes must declare one of three types the app actually accepts. A text/xml or missing type on a POST is refused before any parser sees it.
  • Body size. 10 MB is the global ceiling, sized for statement PDFs, CSVs and supporting documents. Routes tighten it further (see section 5).

4.3 ivt: invalid-traffic guard, in audit mode

"ivt": {
  "mode": "audit",
  "methods": { "deny": ["TRACE", "TRACK", "CONNECT"] },
  "path_denylist": ["/wp-admin", "/wp-login", "\\.php$", "/\\.env$", "/\\.git/"],
  "require_auth_shape": true,
  "strip_header_prefixes": ["X-Internal-", "X-Payments-Internal-",
                            "X-Consumer-", "X-Authenticated-"],
  "burst": { "window_seconds": 10, "max_requests": 200, "key": "ip" },
  "block_threshold": 5
}

IVT scores each request against cheap structural signals: methods no browser sends, paths that do not exist on our stack, an Authorization header that is not even shaped like a bearer token, and more than 200 requests in 10 seconds from one client. Header prefixes that only our own components may set are stripped from the inbound request, so a backend never sees a client-supplied X-Authenticated-User. A score at or above block_threshold becomes a rejection in block mode.

We deployed IVT in audit mode. In audit mode every finding is recorded in the audit line and the request continues. monitor mode also adds an X-WSL-IVT: <verdict>;score=<n> response header, and only block mode rejects. The documented path is audit, then monitor, then block. Header-spoof stripping is the exception: it applies in every mode except disabled, including audit, because removing a header a client should never have been able to set cannot hurt a real user. Starting in audit mode lets us measure the false-positive rate on real traffic before IVT is allowed to say no.

4.4 rate_limit: named profiles, sliding window

"rate_limit": {
  "enabled": true,
  "algorithm": "sliding",
  "default_profile": "standard",
  "profiles": {
    "health":    { "limit": 0 },
    "auth":      { "limit": 60,  "window_seconds": 60, "key": "ip" },
    "public":    { "limit": 120, "window_seconds": 60, "key": "ip" },
    "standard":  { "limit": 600, "window_seconds": 60, "key": "ip" },
    "expensive": { "limit": 30,  "window_seconds": 60, "key": "ip" },
    "upload":    { "limit": 60,  "window_seconds": 60, "key": "ip" }
  }
}

Profiles are named by the kind of work a route does, not by its URL, so the routes table stays readable. limit: 0 means unmetered: probes from Kubernetes and uptime monitors must never be throttled into a false outage. A sliding window avoids the boundary burst of a fixed window, where a client can send twice its allowance across the change of a minute. Responses carry RateLimit-Limit and RateLimit-Remaining, so well-behaved clients can slow down before they are refused.

The auth profile started at 20 requests per minute and was raised to 60 after the first day's incident (see section 8). A login page makes several calls per attempt, and people behind a shared office NAT share an IP.

4.5 audit: one redacted line per request

"audit": {
  "enabled": true,
  "level": "info",
  "sample_rate": 1,
  "include_headers": ["user-agent", "referer"]
}

Every request emits one structured JSON line: tenant, route name, stage decisions, IVT score and reasons, rate-limit result and correlation ID. The lines feed the WSLProxy Grafana dashboards. Only the two named headers are included, and sensitive headers such as Authorization are always redacted. Client identities (IP, API key, JWT subject) are written as tenant-salted SHA-256 digests, never raw, so the same client hashes differently for every tenant. For an app handling payment and account data, this is how the security log avoids becoming a second store of personal data.

5. The route map

Routes are matched in order, most specific first. Each route picks a rate profile, an auth mode and, optionally, a tighter body limit or an IVT override.

RoutePathRate profileBody capNotes
health, ready/health, /ready (equals)health (unmetered)n/aIVT disabled
opsapi-health, fastapi-health/opsapi/health, /fastapi/healthhealthn/aIVT disabled
google-oauth, google-oauth-root/opsapi/auth/google, /auth/googleauthglobalIVT disabled: third-party redirects carry unusual shapes
opsapi-auth, auth/opsapi/auth, /authauth (60/min)64 KBLogin and registration bodies are small
uploads/fastapi/api/v1/statementsupload (60/min)10 MBSupporting documents
fastapi/fastapistandard (600/min)globalAuth passthrough to the service
opsapi/opsapistandard (600/min)globalAuth passthrough to the service
catchall/public (120/min)globalAnything unexpected gets the tightest general limit

Auth is passthrough on application routes: the edge checks that a token is shaped like one, and the services, which own the session model, verify it. WSLProxy can verify JWTs or API keys at the edge. We keep that for a later step so that one change does not alter two security boundaries at once.

6. Isolation and failure behaviour

Every shared-memory key is built through one function: agw:1:<tenant>:<module>:<part>…. Colons and percent signs in any component are escaped, so a crafted header value cannot cross into another tenant's counters. That matters because the same edge also fronts other Workstation and customer domains.

The gateway's failure behaviour is deliberate and written down:

  • Counters fail open. If the shared dictionary is missing or full, or a stage throws, the error is logged, the audit line records unavailable, and traffic flows. A broken rate limiter must not take the payments API offline at peak.
  • Authentication fails closed. If the edge is configured to verify a credential and cannot resolve its key, it returns 401. An edge that cannot verify must not pretend it did.
  • Unknown profile names referenced by a route are not limited and are flagged as unknown_profile, so a typo is visible in the logs instead of silently blocking a route.

7. How we rolled it out

  1. Declare the front door. We first tagged the integration API's origin rules as served by the WSLProxy API gateway and set the gateway header, so the config itself records which component owns edge policy.
  2. Commit the policy. The api_gw block went into the app repository as a normal pull request. On merge, the WSLProxy register workflow, which runs on our self-hosted runners, pushes the changed server record to the London edge through the admin API. No SSH and no nginx reload.
  3. Integration first, audit mode on. INT gets real daily use from the team and testers without exposing customers to a mistake.
  4. Watch the dashboards. IVT would-be blocks, rate-limit headroom per profile and 4xx rates per route are all visible in Grafana from the audit stream.

Because the git file is the source of truth, the register workflow also stops a later sync from quietly reverting a live change made through the admin UI. Commits to the file are the change log.

8. The incident: CORS at the edge broke password login

The first version of the policy also enabled the cors module. It had a tight allowlist of request headers: Authorization, Content-Type, X-Correlation-ID, X-Namespace-Id and X-Request-Id. Because CORS runs at priority 950, the edge answered browser preflights itself, before they reached OpsAPI.

The INT front end sends an X-Project-Code header on password login. It was not on the list, so the browser's preflight came back without it in Access-Control-Allow-Headers. The browser then correctly refused to send the real POST, and password login failed. Google OAuth also passed through the new auth-profile limits, and at 20 requests per minute that was tighter than a real sign-in flow needs.

The fix shipped three minutes after the enabling commit:

  • Hand CORS back to the application. cors was removed from modules. OpsAPI (Lapis) already owned a complete CORS policy, including mandatory fraud-prevention / device-intelligence headers (X-Gov-Client-Device-ID, X-Gov-Client-Screens, X-Gov-Client-Timezone and the rest). The edge policy has been widened to the full header set for the day it is re-enabled.
  • Raise the auth profile from 20 to 60 requests per minute, and the auth body cap from 8 KB to 64 KB.
  • Add explicit Google OAuth routes with IVT disabled, because OAuth redirects legitimately carry query shapes and referrers that structural heuristics may flag.

Three lessons we took from it:

  1. Only one layer should own CORS. When both the edge and the app answer preflights, the stricter one wins silently, and the error appears in the browser console, not in either set of logs. Pick one owner and document it.
  2. Build the header allowlist from real traffic, not from memory. A regulated fintech app sends many custom headers. The audit stream records them, so read it first.
  3. Staged rollout is what made this a three-minute fix. The mistake happened in INT, and reverting it was one small reviewed commit.

9. What we have now

  • One policy for two services. OpsAPI and FastAPI get identical IP handling, request hygiene and throttling without middleware in either codebase.
  • Scanner traffic is visible. Every .env, .git, WordPress and PHP probe is tagged with reasons and a score, ready to block when we switch modes.
  • Login is throttled. Password spraying from a single address hits the auth profile's per-minute ceiling instead of the password hash function.
  • Spoofed internal headers never reach the app, and X-Forwarded-For is only believed from trusted hops.
  • End-to-end traceability. Every request has a correlation ID that appears in the browser response, the edge audit line and the service logs.
  • No new infrastructure. No Kong cluster, no gateway database and no extra hop. The change was a JSON block reviewed like code.

10. Next steps

  1. Move IVT from audit to block on INT once the would-be-block log shows no genuine customer traffic.
  2. Promote the same block to the production API through the same pull-request flow.
  3. Add an expensive route for extraction and calculation endpoints, which today share the standard profile.
  4. Consider edge JWT verification for FastAPI routes, which lets the edge rate-limit per verified user rather than per IP.

11. A checklist for your own API

  • Set real_ip.trusted_cidrs to your CDN and internal ranges before relying on any per-IP control.
  • Turn on correlation IDs first. They cost nothing and make every later decision debuggable.
  • Name rate profiles by kind of work (auth, upload, standard) and leave health checks unmetered.
  • Put the tightest general limit on the catch-all route, not the loosest.
  • Strip internal-only header prefixes at the edge.
  • Start IVT in audit, and roll out to a non-customer environment first.
  • Decide which layer owns CORS, and only one.
  • Keep the policy in git and deploy it with the same pipeline as everything else.

12. FAQ

Is WSLProxy really open source?

Yes. The source, including the api_gw package, its JSON schema, examples and Lua tests, is on GitHub. There is also a migration guide for teams moving from Kong.

Does this replace the WAF?

No. api_gw runs before the WSLProxy WAF engine in the same request. The gateway handles identity, hygiene and quotas. The WAF inspects request content for attack payloads.

What does it cost in latency?

The stages are shared-memory lookups and string checks in the nginx worker, with no network calls. Body inspection is left to the WAF so the body is not buffered twice.

Can I try it on my app?

Yes. The WSLProxy docs describe enabling a new tenant in under ten minutes, and we can help if you would like it run for you.

Workstation designs, implements and maintains API-gateway AI-agent protection for fintech applications. One of the apps we protect in this way sits in the portfolio of a large AI fintech — cognoaiuk.com.

Share this article

More in Technology

Big Prometheus at Scale: Pros, Cons, and a MoSCoW Map of Observability Tools

Big Prometheus at Scale: Pros, Cons, and a MoSCoW Map of Observability Tools

Workstation rewrite of the Big Prometheus problem space: Thanos, Cortex/Mimir, VictoriaMetrics, M3/Chronosphere — plus MoSCoW across OTEL, Grafana, SaaS APM and more

Read more
WSL Vault Technical Deep Dive: Zero-Trust Secrets, Dual Control, MFA & Multi-Region HA

WSL Vault Technical Deep Dive: Zero-Trust Secrets, Dual Control, MFA & Multi-Region HA

Technical deep dive: envelope hierarchy, dual-control workflows, MFA, leases, active/active multi-region HA, feature tours, and presentation chapters

Read more
Workstation AI Shop on Ring Promoter: Orbit, Version-Verified Health and the Path from INT to PROD

Workstation AI Shop on Ring Promoter: Orbit, Version-Verified Health and the Path from INT to PROD

Technical guide: shop Helm/CI mapping, Orbit semantics, version-verified health for the AI agent, secrets per ring, promote API, and a production checklist

Read more