
Auditing Cleo Harmony’s JWT Refresh Flow for Remote Privilege Escalation
What the Cleo Harmony report actually claims
The public report I could verify is narrow: it describes a Cleo Harmony flaw in the JWT refresh flow that can let a remote attacker escalate privileges. That is serious, but the source material I had was thin. I did not have a vendor advisory, a CVE record, or a full technical write-up to validate the edge cases.
Confirmed details from the public report
From the source context, I can confirm only a few things:
- the product named in the report is Cleo Harmony
- the issue is described as a JWT refresh token problem
- the impact is privilege escalation
- the attacker model is remote
That is enough to treat the report as a real security signal, but not enough to pin down the root cause. It might be a broken refresh-token binding issue, a claim-mapping bug, a missing authorization check after refresh, or a combination of those.
What the report does not establish yet
The source material does not establish:
- the affected version range
- whether the bug is in the token issuer, the API gateway, or the application layer
- whether the privilege change happens during refresh itself or on a later API call
- whether the exploit requires user interaction
- whether rotation, revocation, or tenant isolation are involved
- whether the vendor has shipped a fix
That distinction matters because “JWT bug” is often the wrong label. In practice, the token format is rarely the real problem. The real issue is usually that the server treats token claims as if they were authorization decisions.
Why a JWT refresh bug becomes a privilege-escalation bug
Access tokens, refresh tokens, and server-side authorization are different checks
A refresh token should answer one question only: can this client obtain a new access token for the same authenticated subject under the same policy constraints?
It should not answer:
- is this user currently an admin?
- has this user switched tenants?
- has this session been downgraded?
- should this account still be trusted after a password reset or role change?
Those are authorization and session-state questions. If a refresh handler starts reissuing tokens with stronger claims than the original session had, or if it stops checking server-side account state, privilege escalation becomes possible.
A common failure pattern looks like this:
- user logs in as a normal account
- refresh endpoint validates a token artifact
- server reissues a new JWT with role claims copied from stale or attacker-influenced state
- downstream APIs trust the new JWT without rechecking the account record
At that point, the refresh endpoint has become a privilege-minting endpoint.
The dangerous part is not the token format but the trust boundary
JWTs are often treated as self-contained truth. That is the trap.
A signed JWT can still be wrong for the current state of the account. Signature verification only proves the token was minted by someone holding the signing key. It does not prove the claims still reflect:
- the current role
- the current tenant membership
- the current account status
- the current MFA state
- the current revocation state
If Harmony’s refresh path uses claims as the source of truth, then the problem is not “JWT parsing.” It is a trust-boundary failure: the application trusted the token more than its own account database.
Where the implementation usually goes wrong
Refreshing claims without rechecking the current account state
This is the first place I would look.
A safe refresh flow should re-evaluate the user’s current authorization state when it issues a new access token. If the server just copies claims from the old token, the old state gets extended into the next session.
That becomes dangerous if the account has changed since the token was first issued:
- the user was downgraded from admin to operator
- the user was removed from a tenant
- the user was disabled
- the user’s API privileges were narrowed
- a support process revoked elevated access
If the refresh endpoint ignores those changes, it can reanimate privileges that were supposed to be dead.
Failing to bind the refresh token to the right subject, tenant, or role
The refresh token should be tightly bound to the subject and session context that created it.
Weak implementations often miss one or more of these bindings:
subidentifies the wrong user or is not checked consistentlyaudis too broad, so a token can be replayed across servicesissis accepted from the wrong issuer- tenant ID is trusted from client-controlled state
- role or group claims are accepted from the token without server reconciliation
If the token can be replayed in a different context, the attacker may not need to forge a new token. They only need to move a valid token into a more privileged path.
Skipping rotation, replay detection, or revocation on role changes
Refresh token rotation and replay detection are not cosmetic. They are what keeps one stolen token from becoming a long-lived session anchor.
If rotation is absent, an attacker can keep using the same refresh token until it expires.
If replay detection is absent, a previously used refresh token may still work.
If revocation is not tied to password resets, role changes, or logout, stale sessions survive longer than they should.
In a privilege-escalation scenario, the ugly version is this: the original account state changes, but the refresh path never notices, so the attacker gets a new token after the downgrade and keeps the higher privileges.
How I would audit a Harmony-style refresh flow
Trace the login, refresh, and authorization path end to end
I usually split the audit into three questions:
- what is minted at login?
- what is accepted at refresh?
- what is checked at each protected API?
You want to see whether authorization is enforced:
- only at login
- only at token refresh
- on every request
- or never beyond the initial token issuance
The safest answer is: on every request, with refresh only preserving session continuity, not policy authority.
A useful audit artifact is a simple flow table:
| Stage | What to inspect | What should be true |
|---|---|---|
| Login | token claims, session id, tenant, role | claims match server-side account state |
| Refresh | token binding, rotation, revocation checks | same subject, same tenant, no privilege gain |
| API request | authorization check against current state | token alone is not enough |
Compare decoded claims before and after refresh
If you can observe the tokens in a lab or staging environment, decode them and compare the claims.
Example workflow:
python - <<'PY'
token = sys.argv[1]
payload = token.split('.')[1]
payload += '=' * (-len(payload) % 4)
payload = payload.replace('-', '+').replace('_', '/')
data = json.loads(base64.b64decode(payload))
print(json.dumps(data, indent=2, sort_keys=True))
PY "$ACCESS_TOKEN"
What I would compare:
subissaudexpiat- tenant or org identifiers
- role or permission claims
- session or token family identifiers
If a refresh produces a token with a different role, the real question is simple: did the server derive that role from the current account record, or did it trust something stale from the client session?
Test downgrade, logout, and cross-account reuse cases
These are the three checks that usually expose the bug quickly:
-
downgrade test
Issue a token, downgrade the account in the backend, then refresh. The refreshed token should not regain the old privilege. -
logout test
Log out and attempt refresh with the old token. It should fail. -
cross-account reuse test
Try the refresh token from a different account context or tenant. It should fail.
If any of these still works, the session model is too loose.
Look for privilege changes that happen before any backend authorization decision
This is the part that tells you whether the bug is actually dangerous.
If the UI changes first but the backend still rejects the action, you have a client-side display bug.
If the refresh endpoint itself returns a higher-privilege JWT, or the backend accepts the higher-privilege JWT without rechecking the account, then you have a real authorization failure.
That distinction matters. A token claim that looks elevated is bad. A token claim that actually unlocks admin actions is the incident.
Safe lab checks and observable results
Two-account test setup and expected outcomes
I would test this with two non-production accounts:
- Account A: standard user
- Account B: admin or privileged test user
Then I would capture the expected behavior matrix:
| Test | Expected if safe | Red flag if vulnerable |
|---|---|---|
| Refresh Account A token after Account A downgrade | refreshed token reflects downgraded state or fails | refreshed token still contains elevated claims |
| Use Account A refresh token after logout | rejected | accepted |
| Use Account A token against Account B tenant | rejected | accepted |
| Call admin API with refreshed standard token | 403 | 200 |
Example request and response comparisons that prove escalation or rule it out
A safe result looks like this:
POST /api/auth/refresh HTTP/1.1
Authorization: Bearer <refresh-token>
HTTP/1.1 401 Unauthorized
{
"error": "refresh_token_revoked"
}
Or, after a legitimate refresh:
{
"sub": "user-123",
"tenant_id": "tenant-a",
"role": "user",
"permissions": ["read:jobs"],
"exp": 1750000000
}
A suspicious result would be a refreshed token whose claims jump from a user role to an admin role without a corresponding backend account change:
{
"sub": "user-123",
"tenant_id": "tenant-a",
"role": "admin",
"permissions": ["read:jobs", "manage:users", "delete:integrations"]
}
If that happens, I would not call it a JWT issue anymore. I would call it an authorization failure with token minting as the delivery path.
Defensive controls that belong on the server
Recompute authorization on every refresh and API request
Do not treat the JWT as the source of truth for privilege. Treat it as a session artifact.
At refresh time:
- load the current account record
- verify the subject still exists and is active
- verify the tenant context still matches
- verify the current role has not been reduced
- issue a new token only if policy still allows it
At API time:
- enforce authorization against server-side state where possible
- do not rely on the presence of a role claim alone
- prefer coarse claims in the token and fine-grained checks in the backend
Invalidate refresh tokens on role change, password reset, and logout
Revocation has to follow the events that actually matter.
At minimum, invalidate or rotate session state when:
- the user logs out
- the password changes
- MFA changes
- the role changes
- the tenant membership changes
- the account is disabled
If the platform supports delegated administration or managed transfers, revocation should also cover ownership changes and support-driven privilege adjustments.
Add issuer, audience, subject, and rotation-state enforcement
The refresh handler should verify:
issis exactly the expected issueraudis exactly the expected audiencesubmaps to the right current account- the token family or session ID is still active
- the token has not been replayed
If you are not checking those fields consistently, the token can drift away from the identity it was supposed to represent.
Log and alert on anomalous refresh activity
This is not enough on its own, but it helps catch abuse.
Log:
- refresh failures by reason
- token replay attempts
- refreshes after role changes
- refreshes from new geographies or user agents
- refreshes that produce a privilege jump
Alerting on a sudden role increase at refresh time is a good control because it catches exactly the kind of behavior this report implies.
Why this matters for integration and managed-transfer platforms
Admin separation and tenant isolation are the real blast-radius controls
Platforms like Harmony are not ordinary consumer apps. They sit in the middle of file transfer, integration, and operational automation workflows. That means one broken token path can affect more than a single user account.
If tenant separation is weak, an attacker may move laterally across integrations.
If admin and operator roles are not isolated, the attacker may escalate from visibility to control.
If the system trusts refreshed claims too much, a remote attacker does not need local access, code execution, or a browser bug. They only need the session logic to be wrong once.
That is why I would rate this class of bug as high impact even before I know the exact exploit details.
What I would conclude from the report
This looks like an authorization failure first and a JWT bug second
My read of the public report is that the important failure is not “JWTs are broken.” JWTs are doing what they are designed to do: carry signed claims.
The failure is that the application likely accepted refreshed claims without enforcing the current server-side authorization state. That is an authorization bug with a token-refresh delivery mechanism.
That distinction matters because it changes the fix:
- don’t just patch the token parser
- don’t just rotate the signing key
- don’t just shorten expiry
- fix the trust boundary between session refresh and privilege assignment
Short-lived tokens help, but they do not fix a refresh flow that can mint the wrong privileges. I would not ship a system that lets refresh reassert old authority after a role change, logout, or tenant transition.


