I spent the last few weeks building AuditTrail , a small but deliberately serious payment-gateway compliance system, mostly as a way to get real hands-on experience with WSO2 Identity Server. I already knew the theory of OAuth2 and OIDC reasonably well. What I did not expect was how much I would learn from the gap between “the spec says this” and “WSO2 actually does this.” This post is a walkthrough of what I built and more honestly, a log of the moments where my assumptions were wrong.
Before I get into it, a quick refresher in case some of this is new to you. WSO2 Identity Server is what is called an identity provider. Instead of every application in a company writing its own login page and its own password database, applications hand authentication off to one central server and that server issues a signed token as proof of who the user is. OIDC (OpenID Connect) is the standard that defines how that login handoff happens and it is built on top of OAuth2, which is really a standard about authorizing access to resources rather than logging people in. The practical result for a project like mine is two different tokens coming out of the same login, an ID token, which tells the frontend who just logged in and an access token, which the frontend then presents to the backend API as proof that it is allowed to make a given request. Keeping those two tokens straight and not just using whichever one happened to work first, turned out to be a running theme in this project.
Why an audit trail and why make it this strict
The premise I picked for myself was simple. In a payment gateway, when a refund gets issued or a transaction gets flagged as fraud, someone eventually asks who did that, when and under what authority. In a lot of real systems the honest answer is “we are not sure, the logs are incomplete or they were editable after the fact.” I wanted to build something where that answer is never acceptable and to do it in a way that is enforced by the system itself rather than by policy or good intentions.
That led to three non-negotiable design goals.
- Every audit record has to carry an identity that came from a signed token and never from a request field the client could fake.
- Once a record is written, nothing, not even a bug in my own code should be able to change or delete it.
- Different roles should only be able to do what their role permits, enforced at the API layer.
Everything else in the project, the database trigger, the WSO2 configuration and the Spring Security setup exists to serve those three points.
The stack
Spring Boot 3.5 and Java 17 for the backend, PostgreSQL 16 for storage, Flyway for migrations, and WSO2 Identity Server 7.3 as the identity provider. Later I added a React frontend using react-oidc-context and Tailwind , mostly so I would have to deal with the SPA side of OAuth2 too, not just the resource server side.
Making the database itself refuse to lie
Before touching identity at all, I wanted the storage layer to be tamper-proof on its own terms. I did this in two ways.
First, the application connects to Postgres as a role that is only ever granted SELECT and INSERT on the audit table. Postgres roles are database-level user accounts with their own permissions, separate from anything the application code decides. So this is not that UPDATE and DELETE are blocked by application logic, it is that the database user the app connects as literally does not have the privilege to run those statements, full stop, regardless of what the Java code above it tries to do. If someone found an SQL injection bug in my code tomorrow, the database itself would refuse the write, before my own validation logic ever gets a say. This is how I bring “security by design” to the database.
Second, on top of that I added a PostgreSQL trigger, which is a small piece of database-side logic that runs automatically whenever a certain kind of statement is attempted. Mine raises an exception on any UPDATE or DELETE against the audit table, no matter who is asking. This is redundant with the privilege restriction on purpose. If I ever grant myself elevated privileges for some debugging session and forget to revoke them, the trigger is still there as a second, independent barrier.
This part of the project felt satisfying in a very engineering-oriented way. Immutability is not a promise here, it is a property you can go try to break and fail to break.
WSO2 and where my assumptions started breaking
This is the part of the project where I actually learned something new, so I want to spend most of this post here.
My first mental model was that once I registered an application in WSO2 and assigned a user to a role, every token WSO2 issued for that user would naturally include that role. That is not how it works by default. Both the ID token and the access token are JWTs (JSON Web Tokens), which are just a signed, base64-encoded block of key-value pairs called claims , things like who the user is, when the token expires and in this case, what roles they hold. But WSO2 does not automatically decide which claims go into which token. It has a “User Attributes” tab on each application where you decide, attribute by attribute, whether it goes into the ID Token, the Access Token or both. I had roles ticked for the ID Token only, because that is the token I was decoding and reading claims from during early testing. My access tokens were coming back completely clean of role information and for a while I genuinely thought the role assignment itself had not worked, when really the assignment was fine and only the token configuration was incomplete.

Roles enabled for ID token Access Token
The fix, once I found the right tab was one checkbox. But it took a fair amount of decoding JWTs by hand at jwt.io and comparing claim sets between the two tokens before I isolated it.
One set of roles works everywhere
I had originally created my FRAUD_ANALYST and COMPLIANCE_OFFICER roles while testing with a Password Grant application. Password Grant is an older OAuth2 flow where the client collects the username and password directly and exchanges them for a token, which is fine for quick testing with curl or Postman but not something you would expose to a real browser-based app, since it means the frontend handles raw credentials. Those roles worked fine there. Then I registered a second application, a proper Single-Page Application client using Authorization Code with PKCE (the flow meant for browser apps, where the user is redirected to WSO2's own login page instead of typing their password into your app, and a generated secret called a code verifier prevents the returned code from being stolen and reused), for the actual React frontend. Suddenly the same users, logged into the same identity server, were only showing a generic everyone role, with FRAUD_ANALYST nowhere to be found.
The reason turned out to be something called role audience. In WSO2 IS 7.3, when you create a role you have to choose whether it belongs to a single application or to the whole organization. An application-scoped role is only ever visible to the one application it was created under, even if the same user is logged in through a different application entirely. My original roles were scoped to the first app I created, so the second app genuinely could not see them, not because of a misconfiguration in the traditional sense, but because I had unknowingly designed for a single-application assumption that stopped being true the moment I added a second client.
The fix was to recreate the roles with an Organization audience and set the SPA application’s own Role Audience setting to match. Once I did that, both applications could see the same roles, assigned to the same users, which is obviously what I wanted from the start, I just had not understood that audience was a concept I needed to decide on deliberately.
I will admit the first time I made that change, I did not actually see it work, and for a slightly embarrassing reason. I updated the role settings in the WSO2 console, went back to the browser tab where I was already logged into the React app, and reloaded the page. The console log still showed the old everyone-only role. My first thought was that the fix had not taken effect at all. What had actually happened is that reloading the page does not get you a new token, it just reuses the one already sitting in the browser session from before I made any changes. I needed to log out and log back in so WSO2 would issue a brand new token with the updated role information baked in. Once I did that, the correct role showed up immediately. It was a good reminder that a token is a snapshot taken at login time, not something that updates live just because the underlying configuration changed.
An access token is always a JWT
Once roles were flowing correctly into the access token as a claim, I switched my frontend to send the access token instead of the ID token as the API bearer credential, which is the architecturally correct thing to do (ID tokens authenticate the user to the client app, access tokens authorize API calls and I had been quietly conflating the two for convenience). The backend immediately started rejecting every request with a 401.
Turning on debug logging in Spring Security showed the real reason instead of my own generic error message: JOSE header typ (type) at+jwt not allowed. Every JWT has a small header before the actual claims, which includes a typ field describing what kind of token this is. Spring Security's auto-configured JWT decoder, the piece of the backend responsible for checking a token's signature and structure before trusting anything inside it, only accepts a typ of JWT or no typ header at all and rejects anything else immediately, before it even attempts to verify the signature. WSO2, however, issues access tokens with a JOSE header type of at+jwt, following RFC 9068, which is a standard specifically written to distinguish access tokens that happen to be JWTs from ID tokens and other generic JWTs, precisely so that a server cannot accidentally treat one as the other. Spring's defaults simply had not caught up to that distinction.
The fix was to stop relying on Spring’s auto-configuration and build the JwtDecoder bean explicitly, widening the accepted JOSE types to include at+jwt. It is a small amount of code, but it only exists because I would not have known to look for it without reading the actual RFC that WSO2 was quietly following.
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri)
.jwtProcessorCustomizer(processor -> processor.setJWSTypeVerifier(
new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"), JOSEObjectType.JWT, null)))
.build();
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerUri));
return decoder;
}
Small landmine: the shape of the roles claim itself
Even after all of the above, I found that the roles claim was not always the same shape, meaning the actual JSON type of the value changed depending on configuration, not just its contents. With application-scoped roles it arrived as a single comma-separated string, like "FRAUD_ANALYST". With organization-scoped roles it arrived as an actual JSON array, like ["FRAUD_ANALYST", "everyone"]. My original Spring Security converter, the piece of code responsible for turning raw token claims into permissions Spring understands, only handled the string case, splitting it on commas. Given an array instead, it silently produced zero permissions rather than throwing an error, which is a particularly unpleasant kind of bug because it does not look like a bug. It looks like a working authorization check that happens to always say no. I ended up rewriting the converter to check the actual type of the claim first and normalize either shape into the same internal list, rather than assuming one shape and hoping.
The one landmine that was not WSO2’s fault at all
Not every problem I hit was WSO2 being unexpected. One of them was entirely mine. WSO2 Identity Server 7.3 requires Java 21 to run, but my Spring Boot backend targets Java 17, so in the same terminal session I would switch JAVA_HOME to point at Java 21 to start WSO2, and then needed to switch it back to Java 17 before starting the backend. One session, mid debugging, I forgot the second half of that. The backend started fine under Java 21 instead of 17, connected to the database fine and then failed the moment it tried to fetch WSO2's signing keys, with a wall of PKIX path building failed: unable to find valid certification path to requested target.
That error is Java telling you it does not trust the certificate on the other end of an HTTPS connection. Earlier in the project I had already imported WSO2’s self-signed certificate into the Java 17 truststore so the backend could talk to WSO2 over HTTPS locally. I had done that for Java 17 specifically, because that is the JDK the backend normally runs on. Running the exact same backend code under Java 21 instead meant it was reaching for a completely different truststore, one that had never seen that certificate, so the TLS handshake failed before a single line of my own code ran.
Nothing was actually broken. My code was correct, WSO2 was correct, the certificate was correctly imported, just into the wrong JDK’s truststore because I was running the wrong JDK. The fix was to explicitly set JAVA_HOME back to the Java 17 install before starting the backend again. It was a good reminder that a lot of "mysterious" failures in a project with two different Java versions in play are not mysterious at all, they are just a matter of confirming which JDK is actually running right now, not which one you assume is running.
RBAC at the endpoint level
None of the identity work matters if the API does not actually enforce anything with it. This is where RBAC, role-based access control, actually earns its name. It is the idea that permissions are attached to roles rather than to individual users, so instead of checking “is this specific person allowed to do this,” the system checks “does this person hold a role that is allowed to do this.” Every endpoint in the controller is guarded with @PreAuthorize, a Spring Security annotation that runs a permission check before the method body executes at all, against the specific role that should be allowed to call it. A fraud analyst can flag a transaction but cannot pull the compliance dashboard. A compliance officer can view the dashboard and search events but cannot record a fraud flag. And the performedBy field on every audit record is read from the authenticated principal (Spring Security's term for "the verified identity attached to this request"), taken from the sub claim on the verified token, never accepted as a plain value the client supplies in the request body. That last part matters more than it sounds: if performedBy were just another field in the request, anyone could claim to be anyone.
The frontend and seeing the roles work end to end
Building the React frontend was, in a strange way, the moment all of the WSO2 debugging actually paid off in something visible. Logging in as sara and seeing the fraud analyst actions render, then logging in as joe and seeing the compliance summary cards render instead, made the whole chain of trust click into place in a way that reading JWT claims in a terminal never quite did.
What I would tell someone starting this from scratch
If I had to compress everything above into advice for my own past self, it would be this. Do not assume a claim exists on a token just because you configured it somewhere, decode the actual token and check. Decide on role audience deliberately before you have more than one application, because retrofitting it later means recreating roles and reassigning users. And when Spring Security gives you a vague 401, turn on debug logging before you guess, because the real reason is usually one specific line away, not a deep mystery.
Building this project did not make WSO2 feel like a black box to me anymore. It made it feel like a system with a lot of very deliberate, very specific decisions baked into it, most of which are invisible until you hit them directly. That, more than the finished dashboard, is what I actually wanted out of building this.









Top comments (0)