
I built a public sandbox for Chile's Open Finance System, and Keycloak alone did not get there
Chile has an open finance regulation and no public sandbox to test it against. I built a complete unofficial one, faithful to the Technical Annex: participant directory, bank, authorization server and requesting application, with mandatory PAR, an encrypted request object, mTLS and RAR reaching the token. Keycloak 26 covers much of it through configuration, but the consent model Chile picked does not exist in the product: it had to be written from scratch.
Whose data is it
Before the regulation and the technical profile, there is one thing worth fixing: the data was already yours. What open finance changes is not whose it is, but who decides where it goes.
Before
With open finance
That is the whole system in one sentence. The rest of this post is the machinery that makes the sentence true rather than a brochure: how it is proven that it was you, how the permission travels without anyone widening it along the way, and how it is cut off when you say so.
Chile has had an open finance law since 2023 and the rule that regulates it since 2024. There are deadlines, obligations and a technical annex spelling out the exact security profile every participant has to implement. What there is not is a public place where somebody can stand the whole thing up and see whether their implementation works.
There is an official sandbox: it is called ATENA and it lives on the developer portal. It is not public: it admits participants enrolled with the Commission, with their paperwork and their credentials. You cannot open it to inspect its seams at three in the morning, and you cannot read its code.
That is a concrete problem for anyone who is not a participant yet and has to build in order to become one. The annex describes a system of four actors, and three of them are other institutions. Without credentials you cannot test an information service provider against a bank, or a bank against a participant directory, because neither is exposed outside there.
So I built the whole thing. Four actors, six subdomains, and a real end-to-end run that starts at discovery and ends with three accounts read from a make-believe bank.
What came out is a public, unofficial sandbox where the functionality the regulator demands can actually be exercised: mandatory PAR with a signed and encrypted request object, dynamic registration against a software statement, mTLS with certificate-bound tokens, consent carrying its detail, and RAR travelling all the way into the token.
And the short conclusion, the one that would have saved me two days: Keycloak covers much of the profile through configuration, but alone it does not get there. What is missing is precisely the consent piece, and no patch covers it.
What this is and what it is not
Worth being precise, because “sandbox” gets used for very different things.
What it is. An implementation faithful to the Technical Annex in what goes over the wire: the messages, the ordering, the algorithms and the rejections are the ones the regulation asks for. Anyone can stand it up and test their own information service provider or bank against it.
What it is not. A participant in the real system. What separates it is not protocol, it is the chain of trust: the PKI is my own and self-signed rather than the official CA, the Directory is the one I wrote rather than the Commission’s, the data is synthetic, and there is no enrolment or certification involved. That is exactly what you need in order to test, and exactly what stops you from operating.
Put differently: the administrative half is absent on purpose. The technical half is not.
SFA sandbox · rating plate
✓ Aquí puedes
- mTLS with a client certificate, and the token bound to it
- PAR with the request object signed and encrypted
- Dynamic registration against a signed software statement
- The Chilean RAR with its four validation rules
- Grant Management: create, query, revoke and audit
- Breaking it on purpose and reading the exact error it returns
✗ Aquí nunca
- Pointing real credentials or certificates at it
- Moving real people's data
- Presenting it as a participant of the system
- Trusting its PKI: it is self-signed and mine
- Counting on it being available
What the system is and why its profile is unusual
Chile’s Open Finance System requires a bank to hand a customer’s data to another provider when that customer authorizes it. The interesting part for an implementer is not the idea, which already exists in Europe and Brazil, but the technical profile the Commission picked: FAPI 2.0 with mandatory pushed authorization requests, a signed and encrypted request object, mTLS with certificate-bound tokens, and consent expressed as authorization_details from RFC 9396.
That last point changes everything. In classic OAuth the permission is a word: accounts. With RAR the permission is an object naming the accounts, the actions and the level. A token that says accounts audits nothing; one carrying three accounts by identifier does.
There is a fifth actor the regulation takes for granted and that in practice still does not exist openly: the Participant Directory. It says which institutions are active, issues the software statements that enable dynamic client registration, and publishes the public keys signatures are checked against. Without it, every participant would have to hardcode the others, which is exactly what the system is trying to avoid. I wrote that too, with the routes that appear in the official Postman collection.
The customer’s journey
This is the functional journey: what the person authorizing does and sees. The figure walks from one building to another, and the step that matters most is the fifth, because that is where they compare what the application asked for against what their bank shows them. That comparison is the whole system’s defence: if the two screens disagree, something is wrong and she is the one who notices.
The regulator watches from above, which is exactly its role: it never sends or receives a message. And the dotted enclosure matters, because the authorization server belongs to the bank, it is not a third party.
email and password
You reach the application that wants to show you your accounts and sign in with your account there.
Not a formality: the regulation makes a valid consent conditional on the requesting party having authenticated whoever grants it.
permissions · institution · purpose · validity
Before going anywhere you read which data it wants, from which bank, for what service and for how long. And you accept.
The regulation requires being told those four things precisely and clearly before consent is requested.
off to your bank
The application tells you that you are about to move to your bank site.
Your bank password is not typed here, and that is exactly what open finance changes.
you authenticate where you always do
You sign in with your bank credentials, on your bank site.
The application asking for the data never sees them. Fresh authentication is required even if your session was open.
the same thing the app asked for
Your bank shows what is being asked, for what and for how long, and you pick the accounts. Your job here is to see it matches what you read earlier.
The bank is forbidden from altering the request or asking you for anything extra. If what you see here is not what you accepted there, that is the signal.
six-digit code
A code reaches your inbox and you type it.
The strong authentication belongs to the bank, with its branding. Without it there is no consent granted.
Authorised · who and when
Only now does the consent exist as an authorization, with your identity and the time recorded.
The record had been opened at the start, pending. What your confirmation does is grant it, which is what the regulation deems consent.
authorization code
The browser sends you back to where you started.
The application checks that the response belongs to it before doing anything with it.
only the accounts you picked
The accounts and transactions appear, and only for the accounts you ticked.
The permission that travels names those accounts and nothing else, so neither the bank nor the application can stretch it.
The same journey, underneath
That is what you see. Underneath, at every one of those steps, is what makes this the Open Finance System rather than any other integration: certificates, signatures, a dynamic registration and a pushed request. Nobody is walking here, because here it is machines talking. And the Directory steps in, which is what holds the trust between the others.
GET /public/v1/participants
The requesting app asks the Directory who is active and where each API lives.
No participant is hardcoded. If the Directory marks an institution Inactive, it stops being discovered.
JWS PS256 · iss, jti, exp
The Directory issues a signed statement vouching for the app and its URLs.
It is the credential that replaces paperwork, and only the Directory can sign it.
POST /register · software_statement
The app registers at the authorization server by presenting that statement.
Signature, validity, jti and the participant still being Active are all checked before the client is created.
participant transport certificate
All machine-to-machine traffic presents the transport certificate.
No certificate, no token exchange and no data. The edge translates it to URL-encoded PEM, the only form the server accepts.
JWE(JWS) · PS256 + RSA-OAEP/A256GCM
The authorization request is signed and then encrypted.
The order is not interchangeable: encrypting before signing would leave a signature over ciphertext.
request_uri + grantId
The adapter validates the Chilean RAR, mints the grantId and forwards the request untouched.
Forwarding it untouched is what keeps the signature valid. A rejected RAR never reaches Keycloak.
client_request_param_authorization_details
A custom mapper copies the granted authorization_details into the issued token.
The server accepts RAR and does not emit it. Without those lines the bank gets a token that does not say what was consented to.
cnf.x5t#S256
The token carries the thumbprint of the certificate that requested it.
It is what makes a stolen token useless without that certificate private key.
x-consent-id + Bearer
The bank reads the grant, introspects the token and compares before answering.
The Grant Management API is the source of truth, so a revocation cuts access immediately rather than when a cache expires.
POST /grants/{id}/revoke
Revoking moves the grant to Revoked and invalidates the associated tokens.
Only what was Authorised can be revoked; anything else is rejected or expires, and calling that revoked would falsify the audit trail.
What Keycloak does enforce on its own
It is worth starting with the good news, because there is a lot of it. With a FAPI 2.0 client profile applied through policy, Keycloak 26.1.4 correctly rejects all of this without a line of custom code:
| Attempt | Response |
|---|---|
Hitting /auth without going through PAR | 400 Invalid Request |
PKCE with plain | invalid_request |
| Implicit flow | unauthorized_client |
Reusing an already consumed request_uri | 400, single use |
| PAR with no request object | invalid_request_object |
| Exchanging the code without a client certificate | rejected |
That covers a good part of the technical annex. The encrypted request object works too: the sandbox sends it as JWE(JWS) with PS256 inside and RSA-OAEP with A256GCM outside, and Keycloak decrypts it, verifies the signature and processes it.
The order there matters and is not interchangeable. You sign first and encrypt second, because the other way around the signature would cover ciphertext and prove nothing about the content.
Where discovery cannot describe the server
The first problem is neither a security flaw nor a violation. It is that the discovery document cannot describe what a particular client is going to get.
require_pushed_authorization_requestsLo que declara el descubrimiento
falseLo que recibe este cliente
Invalid Requestno lo declaraEl campo dice que el servidor acepta también el camino directo, y para este cliente devuelve 400. El RFC 9126 §4.5 permite exigir PAR por cliente; el descubrimiento solo sabe hablar del servidor entero.
code_challenge_methods_supportedLo que declara el descubrimiento
["plain", "S256"]Lo que recibe este cliente
rechaza plainno lo declaraMismo caso: el perfil FAPI que exige S256 se aplica por política de cliente, y la lista publicada es la del servidor.
authorization_details_types_supportedLo que declara el descubrimiento
ausenteLo que recibe este cliente
acepta el RARno lo declaraPublicarlo es MAY en el RFC 9396 §10, así que omitirlo está permitido. El costo es que un cliente no tiene cómo descubrir qué tipos acepta y hay que decírselo por fuera.
Both columns come from the same server. The left one from its .well-known/openid-configuration, the right one from sending it the request that document claims to allow.
The cause is structural, and worth stating before accusing anyone: those fields describe the whole server, while the requirement lives in the client policy. RFC 9126 §4.5 itself contemplates requiring PAR per client, and discovery has nowhere to express that. I tried setting the fields as realm attributes and the API answered 204 without changing anything.
So the server is not lying: the format gives it no way to tell a truth that depends on who is asking.
The practical problem is still real, because there are clients generated by reading that file. A library reading require_pushed_authorization_requests: false will try the direct path, get a 400, and whoever uses it will go looking for the bug in their own code.
The real gap: RAR goes in and does not come out
Keycloak accepts authorization_details inside the request object. It validates it, stores it in the session and does not emit it in the token.
The result is a token that authorizes without saying what for. The resource server receives a generic permission and the consent is lost exactly where it was supposed to matter. There is no stock mapper for this, and no experimental feature covering it.
De fábricaLo que sí aplica solo
- PAR obligatorio: sin él, 400
- Objeto de petición firmado y cifrado
- PKCE S256; rechaza plain
- request_uri de un solo uso
- mTLS y token ligado al certificado (cnf.x5t#S256)
- Sin flujo implícito
El huecoLo que hubo que escribir
- Grant Management no existeEs el modelo de consentimiento que la norma eligió, y el producto no lo conoce: no publica grant_management_endpoint ni acuña grantId. Hubo que escribir la API entera y un adaptador delante del PAR.
- El RAR no llega al tokenKeycloak acepta authorization_details en el PAR y lo guarda, pero no lo emite. El servidor de recursos recibe un token que no dice sobre qué se consintió.
- No hay pantalla de consentimiento con el detalle que exige la normaLa pantalla genérica no puede mostrar qué cuentas ni por cuánto tiempo: esos datos están en el banco, no en el servidor de autorización.
1384 líneas propias para que un servidor certificable pase de aceptar el estándar a cumplir la norma de punta a punta. El primero de los tres es el que no se tapa con un mapeador.
Finding the cause took four failed attempts. I tried passing the data through the claims parameter, disabling the lightweight token, nesting the structure, sending it loose in the PAR. They all failed the same way: the mapper ran and found nothing.
The answer showed up by logging session notes from inside the mapper. Keycloak prefixes request-object claims when it stores them. What I spent two hours looking for as authorization_details had been there the whole time, under this name:
private static final String PREFIJO = "client_request_param_";
// The data was there. Looking for it under its bare name finds nothing,
// and the mapper walks past without complaining.
String crudo = ctx.getClientSession().getNote(PREFIJO + claim);
A second finding fell out of that, and it explains another lost afternoon: Keycloak drops request-object parameters it does not recognize. Sending a loose consent_id is pointless because it never reaches the other side. The consent identifier has to be pulled out of the RAR itself, which does survive, and which is where the regulation puts it anyway.
There is a formatting detail that also cost time. The value has to be emitted as an array of objects and not as a string:
// authorization_details is an array. Putting it in as a string produces a
// token where the receiver has to parse JSON inside the JSON again.
private Object comoJson(String crudo) {
String t = crudo.trim();
if (t.startsWith("[") || t.startsWith("{")) {
return JsonSerialization.readValue(t, Object.class);
}
return crudo;
}
The authorization server cannot show the consent
This is where the default architecture breaks, and not because of a product limitation.
The regulation requires showing the end customer what is requested, over which accounts and for how long before they authorize. The authorization server holds none of those three. The institution providing the information does, because it created the consent and knows the accounts. Keycloak’s generic screen can list scopes, and a scope called accounts tells nobody anything.
The second factor has the same problem: the emailed code comes from the bank, with the bank’s branding, to the address the customer registered.
The way out was a custom authenticator that breaks off the flow, redirects to the bank with the consent identifier and a return URL, and resumes when the customer comes back:
String vuelta = ctx.getActionUrl(ctx.generateAccessCode()).toString();
ctx.challenge(Response.status(302).location(URI.create(destino)).build());
And on the way back, the part that makes this an authorization rather than a formality:
if (!"autorizado".equals(resultado)) {
// Letting it through would be authorizing without consent.
ctx.getEvent().error("consent_denied");
ctx.failureChallenge(AuthenticationFlowError.ACCESS_DENIED, ...);
return;
}
Placing that authenticator in the flow has its own trap. Making it required at the top level makes Keycloak ignore every alternative and fail with a message about needing a user. Putting it inside the forms subflow works, unless there is a cookie session, in which case it is skipped entirely. The only way to guarantee the screen always appears is to ask for prompt=login from the request object.
Six failures that produce no error
This is the pattern that cost me the most time and the reason this post exists. None of these problems produces a message pointing at its cause.
| Symptom | Actual cause |
|---|---|
| The mapper runs and emits nothing | Claims are stored with the client_request_param_ prefix |
| A request-object parameter disappears | Keycloak drops the ones it does not recognize |
| The token comes out without the mapper’s claims | The lightweight token is on and omits them |
| “requires user to be set” | A required step at the top level cancels every alternative |
| The consent screen never appears | There is a cookie session; prompt=login is missing |
| The callback arrives empty | The action code expired: accessCodeLifespan ships at 60 seconds |
The last one showed up testing with a real person instead of an automated browser. The flow leaves the authorization server for the bank, the person reads the detail, requests a code, opens their email and comes back. That goes past a minute nearly every time. On screen it looks like a broken callback, and only the server log says expired_code.
There was a seventh, which was mine and not Keycloak’s: the bank’s “authorization complete” screen offered two ways forward at once, a link and a timer. The action URL is single use, so the second visit came back as an error after the good one and covered the correct screen. The authorization worked and the interface said it had failed. I fixed it by removing the screen and returning a 302 from the server.
mTLS behind a proxy deserves its own warning
Keycloak reads the client certificate from a header and its nginx provider accepts only URL-encoded PEM. Caddy 2.11 offers PEM with newlines, which an HTTP header will not take and which produces a 502, or base64 DER, which Keycloak does not understand.
There is a third option documented in several places, certificate_pem_urlencoded, which does not exist in Caddy. I confirmed it by searching the binary. When you use it, Caddy passes the literal unresolved and Keycloak reports that no client certificate is available, which looks like an mTLS fault and is not one.
The fix was a tiny bridge that re-encodes, taking care that + comes out as %2B: Java’s decoder turns it into a space and verification fails with an error that also never mentions the cause.
What it cost
| Piece | Size |
|---|---|
| Custom Python (directory, bank, grants, adapters, requesting app) | 4,594 lines |
| Custom Java (three classes, Keycloak providers) | 639 lines |
| Grant Management API | 531 lines |
| PAR adapter validating the RAR and minting the grantId | 214 lines |
| Building and validating the Chilean RAR | 193 lines |
| Checker against the regulation | 316 lines |
| PKI certificates (one CA, four participants) | 13 |
| Subdomains in production | 6 |
The 639 lines of Java are the number that hurts most, because they are the ones configuration cannot avoid: they have to be maintained against an internal API Keycloak itself warns may change without notice, and recompiled against the exact server version because a jar from another one will not load.
But the number that surprises is the other one. The Grant Management API is 531 lines of a service that did not exist anywhere, and the PAR adapter another 214. That is not filling a gap in the product: it is implementing the consent model the regulation chose.
The error a question found
Someone asked me where the regulation says the bank must expose a URL to create the consent before the PAR. I went looking for the clause and there is none.
In the whole Chilean specification there is not a single POST /consents. The only consent endpoints are grant management ones:
| Endpoint | For what |
|---|---|
GET /grants | The customer’s active grants |
GET /grants/{grant_id} | Detail and state |
DELETE or POST /grants/{grant_id}/revoke | Revoke |
GET /grants/history | Event history, authenticated with mTLS |
And the order is the opposite of what I had built. Quoting the RAR specification:
The requesting party sends the Pushed Auth Request (
/par) with one or more objects insideauthorization_details. The Authorization Server verifies coherence between the API scope and the declared actions; if everything is valid, it returns the signedrequest_uri.
It then defines a grant response carrying grantId, status: AwaitingAuthorisation and creationDateTime. Which means: the PAR carrying the RAR is the creation of the consent. There is no separate call, and the identifier is minted by the authorization server, which belongs to the bank.
What I had built was the Open Banking Brasil model. It is the reflex of anyone who has seen open finance before, and in Chile it is wrong.
What fixing it cost
Redoing it moved three things I thought were settled.
The requesting app writes the RAR. The regulation explicitly forbids the bank from altering the content of the request, so asking the bank for the authorization_details was letting it do the drafting. Now it is written by whoever is asking, who is the one who knows what service they intend to provide.
And it names no accounts. Here I hit a contradiction inside the regulation itself. The RAR table marks identifier as mandatory, but the payment initiation specification says of the same field: “Optional. If not informed, the End User will select the account in the Financial Institution’s interface during authorization.” And the general rule lets the bank offer that selection. I followed the operational behaviour, because requiring the identifier would force the requesting app to name accounts it does not know yet, and the only way to learn them would be reading them before consent.
So the person picks their accounts at the bank, with nothing pre-ticked, which is what the regulation demands when it forbids pre-marked options. And the server prunes the grant to what was granted.
The token carries what was granted, not what was asked. The specification describes the response’s authorization_details as an echo of what was granted, verified and pruned by the authorization server. Emitting the original request would produce a token broader than the consent.
I tested it twice with different choices: ticking two accounts yields exactly those two, ticking a single different one yields only that one.
Keycloak’s third gap, and the largest
Grant Management does not exist in the product. Its discovery document publishes neither grant_management_endpoint nor grant_management_actions_supported; the only field carrying the word is grant_types_supported, which is something else.
So on top of the two providers came a service with the full API, an adapter in front of the PAR that validates the Chilean RAR and mints the grantId, and a Java client so the authenticator can move the grant to Authorised when the person signs.
What reading the regulation line by line turned up
I wrote a checker that reads the requirements and probes the running sandbox, storing what comes back. A point without evidence reports as unverified, never as met.
It found a failure that had been there from the start: the security profile requires PS256 and the realm published only RS256. Anyone verifying signatures the way the regulation describes would have found nothing to verify with. Keycloak signs in RS256 out of the box and you have to tell it in three places: the realm key, the default algorithm and the client attributes.
It also turned up a second contradiction. The security profile says purpose is “an informational field that shall not be used for any restriction”, while the CMF validation rules require its value to come from the published list. I resolved it by validating the value and not using it to decide access: what decides is the grant.
One note on the checker itself: the first version reported “does not comply” whenever the client certificate was missing. Not being able to check something is not the same as failing it, and confusing the two turns a checklist into noise.
When it works and when it does not
Keycloak works if you are building a participant and need something running this week. It enforces most of the profile through configuration, it is free, it self-hosts, and both gaps are named with their file in this post.
Keycloak is not enough on its own. With the providers in place the flow complies and the token carries its RAR and its grantId; without them, it does not. Anyone planning an implementation has to budget that code and its maintenance rather than assume it comes included.
And this is a sandbox for development, never for production. It exists so an implementation can be tested against a system that behaves the way the regulation demands on the wire. It does not replace ATENA, it does not operate, and the missing pieces are exactly the ones separating a demonstration from a participant: regulatory retention, propagation between dashboards, and the full consent lifecycle. That goes in the next implementation, which is another post.
And there is an asymmetry worth saying out loud: the gap sits on the consent side, which is exactly the part the regulation protects. What Keycloak gets right is cryptography and transport. What has to be hand-written is the part that explains to a person what they are authorizing.
What comes next
The next version of the sandbox runs on WSO2 Identity Server, and that is the promise I am closing with. The reason is exactly what this post documents: WSO2 ships Rich Authorization Requests as a product feature, with detail types registered through an API rather than deduced from a prefixed session note, and it documents creating FAPI 2.0 applications from its console.
If that means both custom providers become unnecessary, the result of the comparison is the next post. If they do not, that is a post too.
Sources
- CMF · The rule regulating the Open Finance System, Chile’s Financial Market Commission. General Rule 514 and its scope.
- CMF · Technical Annex of the Open Finance System, which defines the security profile this sandbox implements.
- Open Finance Chile developer portal, with the API specifications and the Postman collection.
- RFC 9126 · OAuth 2.0 Pushed Authorization Requests. The PAR the annex makes mandatory.
- RFC 9396 · OAuth 2.0 Rich Authorization Requests. §10 is the one asking for supported types to be published.
- RFC 8705 · Mutual-TLS Client Authentication and Certificate-Bound Access Tokens. The
cnf.x5t#S256that makes a stolen token useless. - RFC 7591 · OAuth 2.0 Dynamic Client Registration, the basis for registering against the Directory’s software statement.
- FAPI 2.0 Security Profile, OpenID Foundation.
- WSO2 Identity Server · Rich Authorization Requests, the native support driving the next version.
Comments
No comments yet. The first one is yours.