Back to blog

IDOR in an SSO Investment Account Closure

Marius Horatau
Written by
Marius Horatau
Published on

A valid SSO session should confirm who is requesting an account closure. HarborVest also lets the request body choose whose account to close, allowing one SSO customer to place another customer's investment account into closure processing.

HarborVest sign-in page offering an email and password form alongside Northstar workplace SSO
HarborVest supports both local passwords and workplace SSO. That choice changes how the account-closure flow confirms a request.

In May 2025, security researcher z3phyrus reported an IDOR in the Firefox Accounts API. The affected POST /v1/account/destroy endpoint accepted an authenticated request while taking the account email from the JSON body. An attacker using SSO could keep their own valid session, supply another SSO user’s email and delete that user’s account without any action from the victim. Mozilla resolved the high-severity report before it was publicly disclosed in June 2025.

The report is a useful example of an authorization check failing at the boundary between two sign-in methods. A password-backed account could require password confirmation before deletion. An SSO-only account had no local password, so the application followed another branch. That branch still needed to prove that the active session belonged to the account named in the request.

HarborVest adapts this failure mode to a workplace investing portal. It is not a reproduction of Firefox Accounts or Mozilla’s implementation. The lab isolates the same security question inside a different product: when a customer starts a destructive action through SSO, does the server bind the target account to that customer’s session?

Note

What is an IDOR? An insecure direct object reference occurs when an application uses a client-supplied identifier to access or change an object without checking whether the signed-in user is allowed to act on it. The identifier does not have to be a numeric ID. An email address can select a security-sensitive object too.

The interface reveals two confirmation paths

HarborVest lets customers review investments, manage recurring deposits and request account closure. Customers can sign in with an email and password or through their employer’s Northstar SSO.

The account-closure page looks almost identical for both types of customer. Its confirmation section contains the important difference:

Password accountNorthstar SSO account
Re-enter the HarborVest account passwordRely on the active workplace SSO session
Type DELETEType DELETE

Sam Chen uses a password account. His form includes an Account password field before the DELETE confirmation. Alex Rivera signs in through Northstar SSO and has no HarborVest password. In the same position, his page says that HarborVest will confirm the closure against the active SSO session.

Both interfaces are reasonable in isolation. Together, they reveal separate server-side confirmation branches. The authorization question is whether the SSO branch confirms Alex’s identity for Alex’s account only, or treats any SSO session as sufficient for any SSO-backed account.

The SSO request carries an account email

Alex’s form submits the following request:

POST /api/accounts/close HTTP/1.1
Content-Type: application/json
Cookie: harborvest_session=<Alex's SSO session>

{
  "email": "[email protected]",
  "confirmText": "DELETE",
  "password": ""
}

The interface does not show an editable email field. The client still includes Alex’s email in the JSON body, where it can be changed independently of the session cookie. The empty password matches the SSO interface: Alex has no local password to enter.

This request contains two statements about identity. The cookie says who is signed in. The email says which customer’s account should be closed. The server has to prove that they refer to the same user before changing account state.

A password account rejects the replay

The first comparison keeps Alex’s SSO session and empty password, but changes the email to Sam’s password-backed account:

{
  "email": "[email protected]",
  "confirmText": "DELETE",
  "password": ""
}

HarborVest rejects the request:

HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "code": "password_confirmation_required",
  "message": "Password confirmation is required before this account can be closed."
}

The response proves that Sam’s password protects his account from this request. It does not establish that the endpoint enforces ownership across every sign-in method. An SSO-only target follows a different branch because there is no password to verify.

An SSO account accepts it

The second comparison changes only the email, this time to Maya Patel’s Northstar SSO account:

{
  "email": "[email protected]",
  "confirmText": "DELETE",
  "password": ""
}

The cookie still belongs to Alex. Maya has not signed in, opened a link or approved the request. HarborVest returns HTTP 200 and names Maya’s account in the response:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "closureId": "clr_GOZ2XVY",
  "accountId": "acc_9KTV61",
  "referenceCode": "CL-631955",
  "status": "pending_closure",
  "receiptUrl": "/accounts/acc_9KTV61/closure/clr_GOZ2XVY"
}

The same SSO session therefore produces two different results according to the target account’s sign-in method. Sam’s password account returns 403. Maya’s SSO account moves into pending_closure.

The confirmation branch never checks ownership

The route authenticates the request and reads the target email from its body:

const current = getCurrentUser();
if (!current) {
  return NextResponse.json({ message: "Sign in required." }, { status: 401 });
}

const email = typeof body?.email === "string" ? body.email : "";

const result = requestClosure({
  targetEmail: email,
  confirmation,
  requestedByUserId: current.user.id,
  sessionAuthMethod: current.session.authMethod
});

Authentication is present: the route knows that Alex is signed in. It also passes Alex’s user ID into requestClosure(). The missing check appears after the function resolves the target from the submitted email:

const targetUser = findUserByEmail(targetEmail);
const targetAccount = getPrimaryAccountForUser(targetUser.id);

if (targetUser.passwordHash) {
  if (!confirmation || confirmation !== targetUser.password) {
    return {
      ok: false,
      status: 403,
      code: "password_confirmation_required"
    };
  }
} else if (sessionAuthMethod !== "sso") {
  return {
    ok: false,
    status: 403,
    code: "sso_session_required"
  };
}

targetAccount.status = "pending_closure";

For a password target, the function checks the target user’s password. For an SSO-only target, it checks only that the request came from an SSO session. Alex satisfies that condition even when targetUser is Maya.

requestedByUserId and targetUser.id are both available, but the function never compares them. A valid SSO session confirms how the requester signed in; it does not authorize that requester to close every account that also uses SSO.

The Base64 encoding applied to password values does not affect this decision. Base64 is reversible, and the SSO request encodes an empty value as an empty value. The failed boundary is the missing relationship between the authenticated user and the selected account.

The demonstrated impact is pending closure

Before the accepted request, Maya’s Stocks & Shares ISA ISA-775203 is open with a balance of £91,758. Afterwards, her own account page shows Pending closure. HarborVest disables market instructions and prepares the holdings and cash for transfer-out or liquidation.

The lab stops at that state transition. It does not execute a real sale, transfer funds or delete Maya’s data. Alex also cannot read Maya’s closure certificate: the receipt returns HTTP 404 through Alex’s session and HTTP 200 through Maya’s. The read endpoint checks ownership even though the closure endpoint does not.

An attacker needs an authenticated SSO session, the email address of another SSO-only customer and an open target account. No victim interaction is required.

Bind the target account to the session

The direct fix is to reject a target that does not belong to the authenticated requester before either confirmation branch runs:

const targetUser = findUserByEmail(targetEmail);

if (!targetUser || targetUser.id !== requestedByUserId) {
  return {
    ok: false,
    status: 403,
    code: "closure_not_allowed"
  };
}

const targetAccount = getPrimaryAccountForUser(targetUser.id);

For a self-service closure endpoint, the cleaner contract is to remove email from the request and derive both the user and account from the authenticated session. The server already knows who is making the request, so the browser does not need to choose the account holder.

Requiring a fresh SSO login can strengthen confirmation of a destructive action, but the returned identity still has to match the owner of the target account. Reauthentication cannot replace object-level authorization.

When a sensitive feature supports several authentication methods, each branch needs its own comparison. A 403 against a password-backed account proves only that the password branch resisted the request. The same action against an SSO-only account can expose a different authorization rule.

Next up

Hashing in cryptography

Hashing is everywhere in security, and for good reason. It’s how we verify data integrity, secure passwords, and much more. In this post, we’ll unpack what makes hashing so useful and see it in action.

© 2026 Uphack.io

RSS Theme