Open finance consent enforcement at the gateway: a how-to for UAE LFIs
A step-by-step guide to open finance consent enforcement at the API gateway for UAE LFIs: mTLS TPP profiles, consent checks per request, and audit-ready logs.
- how-to
- banking
- workflows
- compliance
Open finance consent enforcement works differently in the UAE than almost anywhere else. Under the Central Bank of the UAE's Open Finance Regulation (Circular No. 03/2025), consent is not something each bank captures and stores on its own edge. The framework provides an API Hub with a Trust Framework and Common Infrastructural Services, including a centralized Consent and Authorization Manager that supports the creation, management, enforcement and revocation of consents. Licensed financial institutions (LFIs) connect to that central platform instead of running bilateral integrations with every third-party provider.
Central enforcement does not remove your obligations. As a Data Holder you maintain a dedicated interface giving framework participants access to accounts and products, and Article 16 requires a Data Sharing Provider to act only on the user's explicit consent. The Hub rejects calls under consents it no longer treats as authorized, but your institution keeps consent-relevant records of its own, such as suspensions for suspected fraud, and risk teams often want proof that the bank side honours consent independently of the Hub. That independent check belongs in the request path of your account-data API, where it stops a call before core systems see it and leaves evidence in the same log as the call itself.
This post builds that independent check in Zerq: a dedicated client and mTLS profile for TPP traffic, then a workflow in front of the account-data proxy that validates the request shape, checks consent status, returns a structured 403 when consent is missing, expired or revoked, and forwards valid requests. It ends with the request-log evidence a compliance officer pulls and a go-live checklist. If your account APIs are not behind a gateway collection yet, start with the banking and open banking use case and come back.
Why existing consent approaches fail under a centralized model
Most gateway consent tooling was designed for the UK and EU pattern: bilateral APIs between each bank and TPP, consent captured at the bank's edge, onboarding bank by bank. Kong and Apigee both express consent logic through custom plugins or policy bundles that assume the gateway creates and stores consent. The UAE model inverts that. Consent creation, user authentication and revocation run through the central framework, and your gateway's job narrows to enforcement: a per-request status decision against consent state your institution holds or queries. A consent-capture flow bolted onto your edge duplicates what the framework owns and drifts out of sync.
The second failure mode is where the decision lives. Teams enforce consent in backend middleware or an AWS Lambda authorizer in front of API Gateway; both work until an auditor asks for evidence. A backend check leaves your gateway log showing a 200 or a 403 with no consent context, the reasoning buried in application logs on another system. Lambda authorizers cache decisions for their configured TTL, exactly the window in which a revoked consent keeps returning data. MuleSoft can express the check in a policy, but the decision detail still lands outside the request record you would hand a supervisor.
What the centralized model demands from an LFI gateway is narrow and testable: on every call, resolve the consent, evaluate its status, refuse or forward, and record it all in one place. That is a workflow, and it belongs in the gateway request path.
What the CBUAE framework expects your gateway to prove
Circular No. 03/2025 has been in force since July 2025, replacing the original regulation from 2023, and makes participation mandatory for CBUAE licensees in scope, with banks and insurers in the first onboarding phase. Data sharing and service initiation are in all cases subject to the user's express consent, appropriate authentication and secure communication. Article 22 requires consent to be specific to its purpose, informed, unambiguous and freely given, and users may withdraw it at any time. Article 13 obliges providers to keep records of user consents. The framework's security profile builds on FAPI 2.0 and mandates mutual TLS, with digital certificates issued through the Trust Framework.
Read as engineering requirements for the Data Holder side, CBUAE open finance asks four things of your gateway: machine identity for TPP-originated traffic, consent validation at the API gateway on every request rather than at token issuance, refusals that state their reason in a structured way, and per-request records that tie identity, consent and outcome together. Each maps onto a Zerq capability you configure, not code you deploy.
Building the open finance consent enforcement workflow in Zerq
Here is how to enforce open finance consent at the API gateway, end to end.
Step 1: create a dedicated client and mTLS profile per TPP
Give every TPP integration its own identity, so logs, rate limits and revocation act on one partner at a time. Under the UAE API Hub, though, production traffic reaches an LFI from one caller, so model the Hub as one client and attribute TPPs from the logged o3-caller-* headers; per-TPP clients fit bilateral setups. Our UAE LFI implementation guide covers the hub model, and its Step 5 lists how to adapt this workflow to it.
- In the sidebar, open Clients and click New Client. Name it after the TPP, for example
tpp-fintapp, and under Collections grant access to the account-data collection only. Optionally attach a rate limit Policy for per-TPP throttling. - Open the client's Profiles and click New Profile. Set Profile Name to
production, Active on and Allowed HTTP Methods toGET, leave IP Address Restrictions empty, and click Create Profile. Then click Auth (Manage Authentication) on the profile row and set Authentication Type to mTLS. - Copy the generated X-Client-ID and X-Profile-ID values for your ingress configuration.
Zerq sees your ingress's address, not the TPP's, so enforce partner egress ranges at the ingress. With auth type mtls, Zerq does not validate the Authorization value, but it still rejects a request without one (401), so have the ingress set a fixed Authorization header alongside the identity headers. The ingress validates the client certificate against your CA trust and sets the identity headers from the validated subject. Either issue TPP certificates whose CN and OU carry the client and profile IDs from step 1.3, or map each validated subject to those IDs at the ingress. Zerq then applies policy limits, collection access and allowed methods before any workflow runs: a certificate mapped to a profile that does not belong to the client is refused with a 401, a path outside the client's collections with a 404, and a blocked method with a 405. The header mapping is only safe behind trusted ingress where callers cannot inject those headers themselves; the security model covers this pattern in detail.
Step 2: validate the request shape
- Go to Collections, open the account-data collection, click into the Proxy that fronts your accounts backend, and click Edit Workflow.
- A new workflow starts as
http_trigger -> response_node. Click Add node and place avalidate_nodedirectly after the trigger.
Configure it to reject any request without a well-formed consent identifier:
{
"id": "validate_request",
"type": "validate_node",
"data": {
"config": {
"schema": {
"type": "object",
"required": ["X-Consent-Id"],
"properties": {
"X-Consent-Id": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "pattern": "^[A-Za-z0-9-]{8,64}$" }
}
}
}
},
"inputs": {
"value": "{{ $json['http_trigger'].request.headers }}"
}
}
}
The value to validate goes in data.inputs.value, not in data.config; the runtime reads data.inputs.value and validates it against the JSON Schema in data.config.schema. The trigger hands the workflow the request's method, path, headers, query and body, with header names canonicalised (X-Consent-Id) and each header value an array, hence the array schema. The pattern also keeps characters such as / out of the identifier before Step 3 places it in a URL; the account path itself was already matched by the proxy route. The node branches to valid when the schema passes and invalid when it fails. Wire invalid to a response_node with status_code 400 and a body like {"error": "malformed_request"}, so the consent store never sees garbage and the TPP gets a spec-shaped error instead of a timeout.
Step 3: look up consent status
On the valid branch, add an http_request_node that asks your consent store for the consent's current state: the LFI-side record you synchronize from the central platform, or an internal API in front of it.
{
"id": "check_consent",
"type": "http_request_node",
"data": {
"config": {
"timeout_ms": 2000,
"retry_config": { "max_attempts": 2, "backoff_ms": 200 }
},
"inputs": {
"url": "https://consent.internal.example.ae/v1/consents/{{ $json['http_trigger'].request.headers['X-Consent-Id'][0] }}",
"method": "GET",
"headers": { "Accept": ["application/json"] }
}
}
}
Transport settings live in data.config and the request itself in data.inputs; url is the one required input. The two-second timeout matters: without it, a slow consent store stalls your regulated data path. The node takes its success branch only on a 2xx response; any other status, and any timeout or transport failure, goes to error. Wire both outputs into the condition node in Step 4, which separates an unreachable store from a negative answer. Never route a failed consent lookup toward the backend.
Step 4: branch on consent status
Add a condition_node after the lookup. Conditions are plain JavaScript over $json (no template braces), evaluated in order, first match wins:
{
"id": "consent_gate",
"type": "condition_node",
"data": {
"config": {
"conditions": [
{
"condition": "!$json['check_consent'].response || !$json['check_consent'].response.status_code",
"output": "unavailable"
},
{
"condition": "$json['check_consent'].response.status_code === 200 && $json['check_consent'].response.body.status === 'active' && new Date($json['check_consent'].response.body.expires_at) > new Date()",
"output": "allowed"
}
],
"default_output": "denied"
}
}
}
Wire unavailable (no status at all: the store timed out or was unreachable) to a response_node returning 503 with {"error": "consent_check_unavailable"}. Then note what is absent: no condition lists the deny cases. Only an explicit active status inside its validity window reaches the allowed output. Revoked, expired, suspended, a 404 for an unknown consent identifier, a malformed store response, all fall through to default_output and get denied. Where users may withdraw consent at any time and providers may act only on explicit consent, fail-closed is the only defensible default for consent revocation enforcement.
Step 5: deny with a structured 403, forward when valid
On the denied edge, add a response_node:
{
"id": "deny_403",
"type": "response_node",
"data": {
"inputs": {
"status_code": 403,
"headers": { "Content-Type": ["application/json"] },
"body": {
"error": "consent_not_active",
"consent_id": "{{ $json['http_trigger'].request.headers['X-Consent-Id'][0] }}",
"consent_status": "{{ $json['check_consent'].response.body.status || 'not_found' }}",
"checked_at": "{{ new Date().toISOString() }}"
}
}
}
}
The body names the consent, the status that caused the refusal, and when the check ran. The TPP can show its user something meaningful, and every field becomes searchable in your logs. On the allowed edge, forward to the accounts backend with an http_request_node, mapping the path and the headers it needs explicitly. A proxy_node builds its request only from its own inputs, so with none it would send GET / upstream rather than the original call:
{
"id": "forward_backend",
"type": "http_request_node",
"data": {
"config": { "timeout_ms": 5000 },
"inputs": {
"url": "https://accounts.core.internal/v1{{ $json['http_trigger'].request.path }}",
"method": "GET",
"headers": {
"Accept": ["application/json"],
"X-Consent-Id": ["{{ $json['http_trigger'].request.headers['X-Consent-Id'][0] }}"]
}
}
}
}
Wire both of its outputs to one closing response_node whose data.inputs.status_code is {{ $json['forward_backend'].response.status_code || 502 }} and whose data.inputs.body is {{ $json['forward_backend'].response.body }}. A backend 404 for an unknown account then reaches the TPP as a 404, and a backend that never answers becomes an explicit 502.
Step 6: validate, enable, and test both paths
- Click Validate. It checks for exactly one entry trigger, missing required fields, invalid expression syntax, disconnected nodes and circular dependencies.
- Run Execute Workflow with sample data, or use the
http_triggerListen for Request test mode to capture a real call from your test client. Confirm one allowed and one denied path. - Click Save, then toggle Enable Workflow. Save and Enable are independent: a saved workflow does nothing in production until it is enabled and the proxy revision is published. While disabled, the proxy falls back to plain pass-through forwarding, which here means unenforced consent, so verify the enabled state at go-live.
What the request log records for allowed and denied calls
Zerq logs every request with client identity, latency, headers and both bodies. Because the deny decision runs inside the gateway, allowed and denied calls land in the same log with the same fields. An allowed call looks like this:
{
"request_id": "7c9e4b2a-51d3-4f8e-9a06-2b7d1c3e8f45",
"timestamp": "2026-09-21T09:14:07Z",
"method": "GET",
"path": "/accounts/acc-8c04/transactions",
"target_endpoint": null,
"status_code": 200,
"latency_ms": 131,
"client_id": "6650a1c3e2b94f0d8a7c1101",
"profile_id": "6650a1c3e2b94f0d8a7c1102",
"collection": "account-information",
"client_ip": "10.40.12.7",
"request_headers": { "X-Consent-Id": ["cns-4f19ab27"] }
}
And a denied call from the same TPP, after the customer withdrew consent:
{
"request_id": "d3b81f6e-2c47-49a0-b8e5-7f1a9c04d2e6",
"timestamp": "2026-09-21T09:31:44Z",
"method": "GET",
"path": "/accounts/acc-8c04/transactions",
"target_endpoint": null,
"status_code": 403,
"latency_ms": 38,
"client_id": "6650a1c3e2b94f0d8a7c1101",
"profile_id": "6650a1c3e2b94f0d8a7c1102",
"collection": "account-information",
"client_ip": "10.40.12.7",
"response_body": {
"error": "consent_not_active",
"consent_id": "cns-4f19ab27",
"consent_status": "revoked",
"checked_at": "2026-09-21T09:31:44Z"
}
}
Two details carry the audit weight. The status and body differ: the allowed call returns the backend's 200, while the denied call returns the workflow's own 403 naming consent_not_active, which the workflow only produces on the path that never reaches forward_backend. The target endpoint field is empty on both here, because Zerq fills it only when a proxy_node or a workflow-free proxy forwards the call, so keep the accounts backend's own access log as the record of what was forwarded. (The JSON snippets above are illustrative renderings of the documented log fields.) The response body also preserves the consent identifier and the status that caused the refusal, so one log entry answers who asked, under which consent, what state that consent was in, and what the gateway did. The request ID also goes back to the caller in the X-Request-ID header, a correlation handle when a TPP raises a dispute through the central platform.
How a compliance officer filters for denials
- Open Logs in the sidebar, set Status code to
403, Path to/accounts/*, and the time preset to Last 7 days. - Add a Payload filter for
consent_not_active. Payload search runs across request and response bodies in one query, so it isolates consent denials from other 403s such as IP restriction hits. - To review one partner, add that TPP's Client ID. To trace one dispute, filter by Request ID instead.
- Bookmark the result. Every active filter syncs into the browser URL, so the "consent denials, last 7 days" view becomes a link the compliance team opens each week.
Zerq has no built-in retention setting; enforce your regulatory period in the MongoDB you operate (TTL index or archival job) and in your SIEM, and keep log access role-scoped. Configuration changes, including who edited this workflow and when, land separately in the audit log, which the observability surface exposes to a dedicated auditor role, so reviewers do not need admin rights.
Go-live checklist
- Ingress validates TPP certificates against the trust anchors you accept, enforces partner egress ranges, maps
CNandOUtoX-Client-IDandX-Profile-ID, and sets a fixedAuthorizationheader; identity headers cannot be injected from outside. - One client per TPP (or one Hub client under the UAE API Hub), collection access scoped to account data, an active
mtlsprofile,GET-only methods. GATEWAY_ENABLE_CACHE=false: the shippeddeploy/.envenables a response cache that serves repeat GETs ahead of auth, consent workflows and logging.- Workflow saved, enabled, and the proxy revision published; confirmed the proxy is not running in pass-through fallback.
- Fail-closed paths tested: consent store unreachable returns 503, unknown consent returns 403, revoked consent returns 403 on the next request after revocation.
- One allowed and one denied request verified in the request log, with the consent identifier visible in both.
- Retention period enforced in your MongoDB (TTL index or archival job) and SIEM, and the denial filter view bookmarked by the compliance team.
- After every certificate rotation, one success test and one deny test before the change is closed.
What this looks like in practice
Illustrative scenario, not a specific customer deployment.
A retail bank preparing for phase-one onboarding ran consent checks as middleware inside its accounts service. It worked, but failed the first serious test: proving enforcement in a revocation exercise meant joining gateway access logs with the service's application logs to reconstruct which requests arrived after the withdrawal timestamp and what the middleware decided for each. It took the platform team days, and the evidence was a spreadsheet stitched from two systems, exactly the kind of artifact that invites follow-up questions.
After moving enforcement into a gateway workflow like the one above, the same question became a filtered log view. Revocation now takes effect on the next request (with GATEWAY_ENABLE_CACHE=false), because every request performs the lookup and there is no decision cache waiting to expire. When the compliance officer needs the denial record for a consent, they filter the request log by payload for its identifier and reads the structured 403s directly: backend untouched, reason stated, timestamp included. The accounts service dropped its consent middleware and went back to serving account data.
The UAE built the consent layer centrally so LFIs would not each reinvent it. What remains with you is acting only on explicit consent and being able to prove it. An independent check in the gateway request path does both, and treating it as configuration turns a regulatory obligation into a workflow you can draw, test, version and prove. Everything here is configuration: no plugin builds, authorizer deployments or backend release cycles between you and enforcement that holds up under review.
Zerq is an enterprise API gateway built for regulated industries — one platform for API management, AI agent access, compliance audit, and developer portal, running entirely in your own infrastructure. See how it works or request a demo to walk through your specific requirements.