Let Me Tell You How This Started
It was a random Tuesday night. I had nothing special planned — just me, my laptop, and a cup of chai getting cold on my desk.
I was scrolling through a bug bounty platform looking for new targets. Then I saw it — a fintech company had just opened their program. They handled real money. Digital wallets. Crypto portfolios. Bank transfers.
I thought: This is going to be interesting.
I made an account, opened Burp Suite (a tool that lets you intercept and inspect web traffic), and started poking around. Within 10 minutes of clicking through the app, I noticed something in the request headers.
A long, weird-looking string sitting inside the Authorization header.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI0MjE4In0.SflKxwRJSMeKKF2QT4I recognized it immediately. That's a JWT token. And I immediately started asking questions.
Okay, What Even Is a JWT Token?
Before we go further, let me explain what a JWT is — in plain English.
When you log into a website, the server needs a way to remember who you are. One popular method is to give you a token — a small piece of text that says "hey, this person is logged in, their user ID is 4218, and this token is valid until this date."
That token is called a JWT — JSON Web Token.
It looks like three chunks of text joined by dots:
HEADER . PAYLOAD . SIGNATUREThe Header says what type of token it is and what algorithm was used to sign it.
The Payload is the actual data — your user ID, your role (admin or regular user), when the token was created, and when it expires.
The Signature is like a wax seal. It proves the token hasn't been tampered with.
Here's what the payload looks like after you decode it (you can do this on a free website called jwt.io — just paste the token and it shows you everything):
json
{
"userId": "4218",
"role": "user",
"email": "myemail@example.com",
"iat": 1710000000,
"exp": 1711296000
}The iat means "issued at" and exp means "expires at." Those numbers are Unix timestamps — basically just a way of storing date and time as a number.
So what did I notice when I decoded this token?
The expiry was 15 days from the time it was created.
That's already suspicious. For a fintech app handling real money, 15 days is way too long. Most secure apps use 15 minutes to 1 hour. But the expiry time wasn't even the biggest problem. That came later.
The Three Bugs I Found
I didn't find just one bug. I found three. And together, they were bad. Really bad.
Let me walk you through each one like a story.
Bug #1 — The Token Never Actually Expired
After I noticed the 15-day expiry window, I had a thought: what if I use a token after it's supposed to be expired? Will the server reject it?
So I manually changed the expiry date to a past timestamp. I signed the token again using a weak secret I cracked with a wordlist (more on that later), and I sent the request.
Here's what I sent in Burp Suite:
POST /api/v1/wallet/transfer HTTP/1.1
Host: app.pocketpay.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
[payload with PAST expiry date].
[my re-signed signature]
Content-Type: application/json
{
"toWalletId": "9999",
"amount": 100
}And here's what the server said back:
HTTP/1.1 200 OK
{
"status": "success",
"message": "Transfer initiated",
"transactionId": "TXN-20240309-884421"
}It said success.
The server accepted an expired token and processed a money transfer. That means the server was reading the token but never checking whether it had actually expired. It's like a bouncer at a club who checks your ID to see your name, but never looks at the date to see if it's still valid.
Bug #2 — Old Tokens Still Work After You Log Out
This one is simpler but just as dangerous.
When you log out of an app, your token should be killed. Put on a blocklist. Made useless. That's the whole point of logging out.
So I did this:
- I logged in and saved my token from Burp Suite's history
- I logged out of the app normally
- I took that old saved token and sent it in a request manually
The request I sent:
GET /api/v1/user/dashboard HTTP/1.1
Host: app.pocketpay.com
Authorization: Bearer [my old token from before I logged out]What I got back:
HTTP/1.1 200 OK
{
"status": "success",
"userId": "4218",
"walletBalance": 84250.00,
"email": "victim@example.com",
"transactions": [...]
}Full access. Wallet balance. Transaction history. Everything.
Logging out did absolutely nothing server-side. The token was still alive and kicking. If someone had stolen my token earlier — from a coffee shop WiFi, for example — they could keep using it for 15 whole days, even after I logged out and thought I was safe.
Bug #3 — You Could Use Someone Else's Token to Edit Their Account
This one made my jaw drop a little.
I had two test accounts — let's call them Account A (mine) and Account B (my test victim). I logged into both in separate browsers and collected both tokens.
Now, some API endpoints in this app did something really dumb. Instead of checking on the server "does this user actually own this account?", they just trusted whatever user ID was inside the token.
So I took Account B's token, and used it while hitting the profile update endpoint as if I was Account A.
The request:
PUT /api/v1/profile/update HTTP/1.1
Host: app.pocketpay.com
Authorization: Bearer [TOKEN BELONGING TO ACCOUNT B]
Content-Type: application/json
{
"email": "attacker@evil.com",
"phone": "+91-9999999999"
}The response:
HTTP/1.1 200 OK
{
"status": "updated",
"userId": "5501",
"email": "attacker@evil.com"
}I just changed Account B's email address to one I control. That means I could lock them out of their own account, receive their password reset emails, and fully take over their account — all without ever knowing their password.
How I Decoded the Token on jwt.io
Let me show you exactly how I used jwt.io to investigate the token. This is a free, public tool that any developer or bug hunter can use.
Step 1: Open your browser and go to jwt.io
Step 2: In Burp Suite, copy the long token string from the Authorization header
Step 3: Paste it into the left box on jwt.io
Step 4: The right side instantly shows you the decoded header and payload
This is what I saw:
json
Header:
{
"alg": "HS256",
"typ": "JWT"
}
Payload:
{
"userId": "4218",
"role": "user",
"email": "myemail@example.com",
"iat": 1710000000,
"exp": 1711296000 ← this is 15 days later
}The signature section on jwt.io also lets you test whether the token was signed with a weak secret. I ran the secret through a common password wordlist — basically a dictionary attack — and cracked it. The secret was something embarrassingly simple like secret123.
Once I had the secret, I could generate new tokens with any payload I wanted — including different user IDs, admin roles, or past expiry dates.
Important note: I never used this on real user accounts. All testing was done on accounts I created myself or with explicit permission from the program scope. Ethical hacking means you never touch what isn't yours.
Why This Was So Dangerous — The Real Impact
You might be thinking: okay, so tokens last 15 days and don't expire properly. How bad is that really?
Let me paint you a picture.
Imagine you're using this fintech app on public WiFi at an airport. Someone on the same network intercepts your token — this is easier than it sounds with the right tools. They now have your token.
You land, get home, log out of the app thinking you're safe.
But because of Bug #2, logging out did nothing. The attacker still has your valid token.
Because of Bug #1, that token works for 15 full days with no server-side expiry check.
Because of Bug #3, they can change your email address on the account, request a password reset to their own email, and completely take over your account.
And your money is in that account.
That's a full account takeover chain — three bugs, one very bad outcome.
What Should the App Have Done Instead?
Here's the fix in plain terms, no code required:
For the expiry problem: Tokens should expire in 15 minutes to 1 hour for financial apps. After they expire, the user gets a new one automatically using a "refresh token." The server must always check the expiry timestamp on every single request.
For the logout problem: When a user logs out, the server should add that token to a blocklist — a list of "dead tokens." Every incoming request checks this list. If the token is on it, access is denied, even if the signature is valid.
For the substitution problem: The server should never trust the user ID inside the token alone. It should also check that the user ID in the token matches the resource being accessed. This is called ownership verification, and it's a basic security practice.
What You Should Take Away From This
If you're a developer building apps with JWT:
- Keep your token expiry short — 15 minutes is ideal for sensitive actions
- Always validate the
expclaim server-side, every single time - Implement a token blocklist so logout actually works
- Never resolve who a user is purely from the token — verify ownership on the server
If you're learning bug bounty hunting:
- Always look at
Authorizationheaders — tokens are goldmines - Use jwt.io to decode and analyze any JWT you find
- Test logout flows — they break more often than you'd think
- Test whether expiry is actually enforced, not just set
If you're a regular user of financial apps:
- Log out and back in regularly on sensitive apps
- Avoid using fintech apps on public WiFi without a VPN
- Enable 2FA wherever possible — it adds a layer even if tokens are leaked
This wasn't some complicated, exotic attack. It wasn't reverse engineering or binary exploitation. It was just careful observation, basic tools, and asking the right question: does the server actually trust what it says it checks?
In this case, it didn't. And that's how bugs like this survive in production — not because developers are careless, but because JWT security is easy to get wrong in subtle ways that don't show up in normal testing.
The web is full of these quiet, patient bugs. All you have to do is look.
Happy hunting. Stay legal. Report everything.
If this helped you, share it with someone learning security. That's how this community grows.