Back to Research

Orphaned Accounts to Full Control: An Authentication Logic Flaw

Abusing accounts tied to deactivated organisations to reach a hidden signup endpoint, bypassing an invite-only model to create arbitrary accounts and self-select privileged roles.

During a penetration test of a mentoring platform, I found an edge case that let me bypass both the authentication logic and the business rules built on top of it.

By abusing how the application handled orphaned accounts, users tied to deactivated organisations, I reached a hidden signup endpoint that let me:

  • Create new accounts, even though the platform forbids open sign-ups
  • Join any organisation of my choice
  • Self-select my role, mentor or mentee, bypassing the normal approval flow

A combination of logic and authentication flaws that unravelled the entire onboarding model.

#The platform's rules

The application hosts mentoring programmes and has two distinct areas:

  • Admin Portal, for organisation admins to manage orgs and invite members
  • Member Portal, for both mentors and mentees

Only two account creation paths are permitted by the business rules:

  • Admin invite flow. An admin invites a user by email.
  • Mentee request flow. A mentee applies through a form; a mentor accepts or rejects.

No open signup is allowed. The UI gives you no way to simply create an account.

The member portal with no signup option available

#The authentication flow

The platform uses Google Identity Toolkit, Firebase style. Sign-in happens in two steps.

#Step A: exchange email and password for a Google ID token

http
POST /mentoring/authorize/google-proxy/https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword?key=****************** HTTP/2
Host: api.application.com
Content-Type: application/json
X-Client-Version: Firefox/JsCore/8.10.1/FirebaseCore-web
Origin: https://my.application.com
 
{"email":"email@com.org","password":"rmdndodo.com","returnSecureToken":true}

The Google Identity Toolkit proxy request

#Step B: hand off the ID token with organisation context

http
POST /mentoring/authorize/signup/complete
Content-Type: application/json
Origin: https://my.application.com
 
{
  "state": "A0DB...<very long state blob>...",
  "idToken": "eyJhb...<google_id_token>",
  "organizationId": "org-Id"
}

Response:

json
{
  "date": "2024-10-01T16:00:31.446Z",
  "reasons": ["Reached User"],
  "isSuperAdmin": false,
  "employeesStatusCode": "A",
  "lastModified": "2024-10-01T16:25:25.245Z",
  "id": "G0THVY...<user_id>",
  "Bearer": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...<app_access_token>"
}

The completed signup response carrying the application bearer token

#First attempts, both dead ends

#Testing IDOR in organizationId

Every request after login carried an organizationId, so the obvious first move was an Insecure Direct Object Reference attempt. I swapped organizationId values across API calls and replayed requests with different IDs.

Result: nothing. The backend consistently validated the organisation against my token. No leaks, no cross-org access.

#Trying to trick the invite flow

Next I attacked the invite logic. Using an admin account I invited another email I controlled, then deleted that account from the organisation, hoping the application would leave it partially unlinked and still allow login.

Result: the account was completely removed and later logins failed.

#The long pause

Hours in, with no IDOR, no invite trick and no easy bypass, the logic looked tight.

So I changed approach. Instead of pushing harder on the happy path, I started hunting for weird states, edge cases where an account might not behave as the developers expected.

With permission from the testing team, I pulled leaked credentials from the dark web. Many were old emails tied to deactivated organisations. That is where it got interesting.

#The orphaned account

Logging in with one of these inactive-organisation accounts worked, but the response was different.

The client first calls Google Identity Toolkit through the application's proxy:

http
POST /mentoring/authorize/google-proxy/https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword?key=***********
Host: api.application.com
Content-Type: application/json
 
{"email":"email@com.org","password":"rmdndodo12com","returnSecureToken":true}

Then it exchanges that ID token with the backend:

http
POST /mentoring/authorize/signup/complete
Content-Type: application/json
Origin: https://my.application.com
 
{
  "state": "A0DB...<long blob>",
  "idToken": "eyJhb...<google_id_token>",
  "organizationId": ""     // ← empty!
}

Here is the inconsistency. Normally organizationId is fixed to the user's organisation. For this deactivated-organisation account it arrived empty in the body, even though the JWT still carried an inactive organizationId.

That was the logic breakdown. If the field was empty, could I supply my own?

#Injecting my own organisation

I replayed the request, this time setting organizationId to a real, active organisation I had captured earlier:

json
{
  "state": "A0DB...<same state>",
  "idToken": "eyJhb...<google_id_token>",
  "organizationId": "A********"  // injected org
}

The backend accepted it. I was redirected, but not into the organisation. The application handed me a path that is not normally reachable at all.

#The hidden signup endpoint

The redirect happened because of a confusion in the login logic: the account's token still carried an inactive organizationId, while my request supplied a new active one. The backend never validated that mismatch and instead treated the session as a valid signup flow, exposing a hidden endpoint:

http
POST /mentoring/authorize/signup
Host: api.application.com
Content-Type: application/json
 
{
  "type": "emailInvite",
  "email": "rmdn@gma1l.com",
  "organizationId": "J**********",
  "password": "Aa123456!@#",
  "firstName": "rmdn",
  "lastName": "doda"
}

Response:

json
{ "accountCreated": true }

The hidden signup endpoint confirming account creation

Unlike the UI, from here I could:

  • Set any email I wanted
  • Create accounts without an invite
  • Choose my own role during onboarding, mentor or mentee

Arbitrary account created outside the invite flow

Landing inside the target organisation with a self-selected role

#Recap

  1. Tried IDOR on organizationId. Failed, the backend validated against the token.
  2. Tried the invite-then-delete trick. Failed, the account was fully removed.
  3. Found an orphaned account, where organizationId arrived empty in signup/complete.
  4. Injected a valid active organisation ID. The server accepted it and issued a token.
  5. Got redirected to a hidden /signup, and created accounts with arbitrary emails.
  6. Self-selected a role, and landed inside the organisation as mentor or mentee.

#Root cause

The backend trusted a client-supplied organizationId in a state where its own token said something different, and never reconciled the two. An empty field in an unexpected account state was treated as "not yet onboarded" rather than "invalid", which routed an already-authenticated user into a signup flow that should never have been reachable.

The fix is to treat the organisation in the token as authoritative, reject any request where the supplied organisation contradicts it, and refuse to enter a signup flow for a principal that already exists.