The ownership check I kept on reads (GET), I left out entirely on writes (POST) — an IDOR I almost shipped in the account API

Security IDOR Authorization Spring Boot API

Introduction

I’m building, on my own, a banking-style system that handles accounts — and actually running it, not just designing it. The overall structure, screens, and backbone code were covered in What’s Actually Running in My Homemade Banking System. Customers check balances and move money in and out from the customer-facing web screen (React). Below is the account list screen, where a user’s own accounts are listed.

Account list screen (a user's own accounts — demo data for this project. Not a real account or balance)

This article is about one hole behind that screen, in the account API — one I closed before shipping.

The hole is called IDOR (Insecure Direct Object Reference). In short: “login succeeds correctly, but if you swap the account number in the URL for someone else’s, you can operate on their account.” Authentication (who are you) was working. What was missing was authorization (are you allowed to touch this account).


What could have happened

In this API, I built the read side (GET) first, in that order. Balance inquiries and transaction history had an ownership check that made sure they returned only the authenticated user’s own accounts. That part was correct.

The problem was the write side (POST). The design (pseudocode) for withdrawals and transfers never had that ownership check written into it. Implement the pseudocode faithfully, and you get this:

Authenticated request (identity already confirmed)

        ├─▶ GET /accounts/{id}/balance (read)
        │        └─ Ownership check present ─▶ returns only your own account ○

        └─▶ POST /accounts/{id}/withdraw (write)
                 └─ No ownership check

                        └─▶ swap {id} for someone else's account ─▶ withdraw from someone else's account ×

                      (authenticated fine — only authorization is missing)

The login itself is legitimate. So at a glance it looks properly protected. But the write endpoint never checks whether the {id} in the URL belongs to the caller. As long as you’re authenticated, you can specify someone else’s account number and execute a withdrawal or transfer. In a financial API, that’s a straight path to an incident.

Looking at the implementation, here’s the gap: the read endpoint (balance inquiry) covered in the previous article had an ownership check; the write method had none.

// Write endpoint (withdraw) — version without an ownership check
@PostMapping("/accounts/{id}/withdraw")
public void withdraw(@PathVariable String id,
                     @RequestBody WithdrawRequest req,
                     @AuthenticationPrincipal Jwt principal) {

    // We received the principal (the caller's identity),
    // but never checked whether account {id} actually belongs to them
    accounts.withdraw(id, req.amount());   // ← goes through even for someone else's account id
}

The caller’s identity (principal) is right there. And yet it’s never matched against whether account {id} belongs to that person. The match that the read side did as a matter of course simply isn’t here.


Why it happened

Two causes overlapped.

One: the carelessness of treating authentication and authorization as the same thing. Authentication confirms “who you are.” Authorization confirms “whether you’re allowed to do this, to this target.” Login succeeding does not mean it’s safe. Someone who logged in isn’t automatically allowed to touch someone else’s account. These are two separate gates.

Two: a pattern established on the read side doesn’t automatically carry over to the write side. Even after building the read side first and establishing the ownership check there, if the write side’s design is left as pseudocode only, that check never gets copied over. And if the pseudocode gets implemented mechanically as “the spec,” the gap ships as-is. The order of development itself (reads first, writes as pseudocode only) quietly created the gap.

In other words, this wasn’t so much an individual lapse as a structural hole produced by the way the work was sequenced.


How I closed it

I ported the same ownership check from the read side onto the write side. If the identity extracted from authentication doesn’t match the owner of the account being operated on, the operation is refused.

POST /accounts/{id}/withdraw (write)

        ├─▶ Authentication: extract the caller's identity (sub) from the token

        └─▶ Authorization: does the owner of account {id} == the caller?
                 ├─ match ──▶ execute the withdrawal ○
                 └─ no match ─▶ reject (403) × never touch someone else's account

In code, it’s just moving the few lines that already existed on the read side over to the write side.

// Write endpoint (withdraw) — version with the ownership check added
@PostMapping("/accounts/{id}/withdraw")
public void withdraw(@PathVariable String id,
                     @RequestBody WithdrawRequest req,
                     @AuthenticationPrincipal Jwt principal) {

    String me = principal.getSubject();
    Account account = accounts.findById(id);
    if (!account.getCustomerId().equals(me)) {   // same ownership check, ported from the read side
        throw new AccessDeniedException("not your account");
    }
    accounts.withdraw(id, req.amount());
}

It’s not a difficult fix. What’s difficult is noticing the assumption that “it’s protected on reads, so it’s protected on writes too.”

And this wasn’t the only place I fixed. Every write endpoint (POST / PUT / DELETE) that takes a resource ID from the URL path became equally suspect. Finding one means other endpoints built in the same order are likely to have the same hole.

One more thing I made sure to do: write the authorization back into the design docs. If you only fix the implementation and leave the design doc alone, the next person who reads the same pseudocode (including future me) will reproduce the same gap. Closing the hole and fixing the blueprint of the hole are two separate jobs.

One caveat: this is a different premise from a “role-based” API meant for something like a teller/operator, where handling other people’s accounts under job authority is expected behavior. This is a customer self-service API, where operations are meant to be limited to the caller’s own account. Even for the same “API that operates on accounts,” the shape of authorization changes depending on who the feature is for. Not conflating the two was also part of closing this hole.


Don’t mistake authentication for authorization

Authentication is not authorization. Knowing “who” doesn’t mean “allowed to do this.” The moment you think “login succeeded, therefore it’s safe,” you’ve skipped the authorization gate.

Carry authorization established on the read side over to the write side by hand. Don’t relax just because GET is protected. Always confirm whether the design for POST / PUT / DELETE has the same ownership check written in. Read pseudocode not as a finished spec but as “a draft that might still be missing authorization.”

Suspect every write endpoint that takes a resource ID from the URL. Once you find one, sweep the sibling endpoints built the same way. Holes don’t open alone.

Fix the design doc’s authorization along with the implementation. Fixing only the implementation leaves a blueprint that will reproduce the same hole. Leave the trace of the fix in the design to cut off the seed of recurrence.

Pseudocode isn’t a correct answer meant to be implemented as-is. Before you touch the keyboard, always confirm one line: is the authorization that protects the read side also written into the write side?


Feel free to send a message

Job offers, project referrals, feedback, questions — anything is welcome. I sincerely hope to connect with people who share high ambitions. I will keep taking on the challenges I have staked my life on. Thank you very much.