Auditing Cleo Harmony’s JWT Refresh Flow for Remote Privilege Escalation

Auditing Cleo Harmony’s JWT Refresh Flow for Remote Privilege Escalation

pr0h0
cybersecurityjwtvulnerabilityaccess-control
AI Usage (98%)

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:

  1. user logs in as a normal account
  2. refresh endpoint validates a token artifact
  3. server reissues a new JWT with role claims copied from stale or attacker-influenced state
  4. 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:

  • sub identifies the wrong user or is not checked consistently
  • aud is too broad, so a token can be replayed across services
  • iss is 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:

  1. what is minted at login?
  2. what is accepted at refresh?
  3. 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:

StageWhat to inspectWhat should be true
Logintoken claims, session id, tenant, roleclaims match server-side account state
Refreshtoken binding, rotation, revocation checkssame subject, same tenant, no privilege gain
API requestauthorization check against current statetoken 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:

  • sub
  • iss
  • aud
  • exp
  • iat
  • 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:

  1. downgrade test
    Issue a token, downgrade the account in the backend, then refresh. The refreshed token should not regain the old privilege.

  2. logout test
    Log out and attempt refresh with the old token. It should fail.

  3. 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:

TestExpected if safeRed flag if vulnerable
Refresh Account A token after Account A downgraderefreshed token reflects downgraded state or failsrefreshed token still contains elevated claims
Use Account A refresh token after logoutrejectedaccepted
Use Account A token against Account B tenantrejectedaccepted
Call admin API with refreshed standard token403200

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:

  • iss is exactly the expected issuer
  • aud is exactly the expected audience
  • sub maps 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.

Further reading

Share this post

More posts

Comments