JWT misconfiguration leads to zero-click account takeover and PII exposure

Recently I came across a relatively lesser known bug bounty platform and decided to hunt on it. While testing the program, I discovered that its JWT-based authentication could be manipulated to gain unauthorized access to arbitrary user accounts. This resulted in zero-click account takeover and exposure of sensitive PII including Fullname, email, phone.no, Pan-card, Aadhar card etc.
I responsibly reported the vulnerability and was rewarded for it. In this writeup, I will detail my testing flow and describe the vulnerability. I will be referring to the domain as 'example.com' in order to comply with the program policy.
The Approach
My main objective as I started was to test for access control vulnerabilities. But we need to start with threat modeling. Understanding what we are attacking is important if you want to be effective in identifying what might be vulnerable.
This video by Hackerone does a good job explaining two different approaches to Threat modeling.
My threat modeling approach summarised is:
Walk the application — Turn on your burp proxy on and use the application like a normal user, write down each feature in the application.
Review the features and rank them by severity of most sensitive to the least sensitive.
- For eg: A Delete account feature is more sensitive than a mailing list subscribe feature.
Fingerprint the application — This is an important step as it narrows down a lot of attacks. Identify the stack using various methods such as through 404 pages, response headers, Route naming style. Also identifying how/where the webapp is hosted (Cloud) may help you later.
Access Control
As I'm focusing on breaking the Access control, I need to gather as much information as possible on how it is implemented.
You can infer or gather this information using various ways such as by Checking HTTP headers (X-Powered-By, Server), error messages, file extensions in URLs (.php, .jsp, .aspx), session cookie names (PHPSESSID, JSESSIONID), and framework indicators. Look at job postings and it's required stack, GitHub repos, or use Wappalyzer/BuiltWith tools.
Information I gathered based on recon and fingerprinting:
Full-stack NextJS with server-side rendering.
Uses a custom OAuth2 + OIDC setup with a self-hosted identity provider (probably Keycloak) for authentication.
A RS256 JWT is used as access token and establishes the identity. A HS256 refresh token is also issued.
The authentication has one single method of which is to signup using phone-number and enter the OTP. After submitting the OTP we are provided with an access token and a refresh token.
Decoding the JWT access token, it had the following structure:
{
"alg": "RS256",
"typ": "JWT",
"kid": "[UNIQUE KEY ID]"
}
{
"exp": 1078135200,
"iat": 946688461,
"auth_time": 1771239939,
"jti": "22a8c363-[REDACTED]-600e4c801a02",
"iss": "https://auth-prod.internal.example.com/realm/example",
"sub": "XXja5ilsvWQ",
"typ": "Bearer",
"azp": "example_webapp",
"session_state": "396049a9-[REDACTED]-e25eb305de3e",
"scope": "offline_access",
"sid": "396049a9-[REDACTED]-e25eb305de3e",
"migration_keys": {
"user_id": "123456789" // this is sequential
},
"phone_number": "1231231231"
}
It is quiet apparent that migration_keys.user_id if changed would enable us to change our identity, that is login to other's account, it being incrementing and guessable definitely does help. But it is signed using a private key, unless we know it, we cannot tamper with the JWT.
At this point, my idea was to go step by step. I created a comprehensive checklist covering all kind of testing like testing for OAuth 2.0 related bugs such as messing with the redirect_uri, etc.
Finding the Bug
After testing and checking off many bugs, one of the tests was to check for JWT Forging using various attacks. One of the classic ones - setting alg to none checks whether the backend skips signature verification entirely when told to.
I changed the alg header to none, stripped the signature part, and replayed a request to the dashboard.
It went through.
The backend wasn't verifying the signature at all. it was just extracting migration_keys.user_id directly from the token payload and trusting it. Which means an attacker can decode any JWT, swap that field to any sequential user ID, and replay it.
PII Exposure
Before testing the access control, I had mapped the various requests send by the client side and one of the request was a GET Request to a subdomain — workspace.example.com (This is in scope.)
A simple GET request is sent to https://workspace.example.com/api/fetch-profile/ with a custom header called Rtoken, this is the same token as the access-token. The endpoint responds with some interesting PII information some of which are:
pancard
aadhar_front
aadhar_back
is_staff
is_admin
and many interesting information collected by the application and is important to the working of the service. On my threat modeling I had already marked this as a sensitive feature that must be checked.
This endpoint unlike the other one seems to be running Django as per my fingerprinting (default 404 page, usage of ?next= parameter etc.)
I did the same test as above by removing the signature part from JWT, changing the alg header to none, updating migration_keys.user_id in access-token to that of my other account. And I was able to access the PII information of my other account successfully.
I reported this as a separate vulnerability as I believed the subdomain and backend application is different hence another implementation, but it was marked as duplicate (High severity) as the inherent root cause was same, which points towards the use of a central authentication server.
Mitigation
Based on how the backend behaves, it seems to be extracting user_id directly from the token payload without verifying the signature. This can be fixed by validating the signature against the public key before trusting any claims.
Example on signature verification using jose library in nextjs:
const { payload } = await jwtVerify(accessToken, PUBLIC_KEY, {
algorithms: ['RS256'],
});



