The ownership check I kept on reads (GET), I left out entirely on writes (POST) — an IDOR I almost shipped in the account 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.

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?
Related articles
- The full tour of this system’s structure, screens, and backbone code is in What’s Actually Running in My Homemade Banking System (the premise for this article).
- If this article is about “whether a person can only touch their own resources” (authorization), the layer before that — “who the caller even is” (authentication) — for non-human, service-to-service calls, protected without handing over any hints, is covered in “Authenticating a Party That Isn’t a Person” — Protecting a Service-to-Service Payment Call Without Handing Over Any Hints.
- The design process from requirements to PoC is covered in Building a Banking API That Starts From Design.
- The story of using this account API to actually connect a transfer from my own platform’s payment flow is in Connecting My Own App to My Own Bank Until Real Money Moved — Adding Bank Transfer to Payments and Verifying It End-to-End.
- On the authentication side, the design that relaxes authentication only in dev without breaking the production structure is covered in A Design That Relaxes Authentication Only in dev, via Spring Profile.