IDOR — the most common bug in the wild

Cross-account access by guessing IDs.

8 minmedium

IDOR — Insecure Direct Object Reference — is the most common security bug in shipped APIs. It's also the easiest to fix. This lesson: how it happens, how to exploit it on your own endpoint, then ship the fix in three lines.

The attack, in one diagram

IDOR exploitation

User A (legit) API DB │ │ │ │ PATCH /api/notes/42 │ │ ├───────────────────────►│ │ │ (token: A) │ UPDATE notes │ │ │ WHERE id = 42 │ │ ├────────────────────►│ │ │ ✓ │ │◄───────────────────────┤ │ │ 200 OK │ │ │ User B (attacker) │ │ │ PATCH /api/notes/42 │ │ ├───────────────────────►│ │ │ (token: B) │ UPDATE notes │ │ │ WHERE id = 42 │ ← no ownership check! │ ├────────────────────►│ │ │ ✓ │ │◄───────────────────────┤ │ │ 200 OK — note 42 now belongs to B's content
The attacker is authenticated. They just guess (or enumerate) another user's ID and access data they shouldn't.

The vulnerable code

apps/api/internal/handlers/note_handler.go — DELIBERATELY BROKEN
func (h *NoteHandler) Update(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var in services.UpdateNoteInput
c.ShouldBindJSON(&in)
// 🚨 BUG: no check that the note belongs to the authed user
var note models.Note
h.db.First(&note, id)
h.db.Model(&note).Updates(map[string]any{"body": in.Body})
c.JSON(200, gin.H{"data": note})
}

Three problems in this code:

  • The handler reads c.Param("id") without scoping by the authed user.
  • The handler updates the row whose id matches the URL, regardless of ownership.
  • The handler doesn't even check whether the row exists before updating.

Exploit it yourself

# 1. Register User A
curl -s -X POST localhost:8080/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"a@a.com","password":"secret123","name":"A"}' | jq
# 2. Get A's token
TOKEN_A=$(curl -s -X POST localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"a@a.com","password":"secret123"}' | jq -r .data.token)
# 3. A creates a private note
curl -X POST localhost:8080/api/notes \
-H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' \
-d '{"title":"Private","body":"My secret"}'
# returns {data: {id: 1, ...}}
# 4. Register User B (the attacker)
curl -s -X POST localhost:8080/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"b@b.com","password":"secret123","name":"B"}'
TOKEN_B=$(curl -s -X POST localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"b@b.com","password":"secret123"}' | jq -r .data.token)
# 5. B overwrites A's note
curl -X PATCH localhost:8080/api/notes/1 \
-H "Authorization: Bearer $TOKEN_B" \
-H 'Content-Type: application/json' \
-d '{"body":"hacked"}'
# returns 200 OK — and note 1 now says "hacked"

Five steps. Two minutes. B never had access to A's account but rewrote their data. This is IDOR.

The fix

apps/api/internal/services/note_service.go (the fix)
var ErrNotFound = errors.New("not found")
var ErrForbidden = errors.New("forbidden")
func (s *NoteService) Update(ctx context.Context, userID, id uint, in UpdateNoteInput) (models.Note, error) {
var note models.Note
if err := s.db.WithContext(ctx).First(&note, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { return note, ErrNotFound }
return note, err
}
// 🔒 Ownership check — the fix
if note.UserID != userID {
return note, ErrForbidden
}
updates := map[string]any{}
if in.Title != nil { updates["title"] = *in.Title }
if in.Body != nil { updates["body"] = *in.Body }
if err := s.db.WithContext(ctx).Model(&note).Updates(updates).Error; err != nil {
return note, err
}
return note, nil
}

Three lines of fix:

  • Load the note first.
  • Compare note.UserID to the authed user.
  • Return ErrForbidden if they don't match.

The handler maps to 403

switch {
case errors.Is(err, services.ErrNotFound):
c.JSON(404, gin.H{"error": gin.H{"code": "not_found"}})
case errors.Is(err, services.ErrForbidden):
c.JSON(403, gin.H{"error": gin.H{"code": "forbidden"}})
case err != nil:
c.JSON(500, gin.H{"error": gin.H{"code": "internal"}})
default:
c.JSON(200, gin.H{"data": note})
}

Re-run the exploit — B gets 403. A still gets 200. Vulnerability closed.

404 vs 403 — which to return? Some teams return 404 ("not found") even when the row exists but is forbidden — to avoid leaking the existence of the resource. For most apps, 403 is fine. For high-stakes apps (medical, legal), 404 is paranoid-correct. Pick a policy and apply it everywhere.

The pattern for ALL access control

  1. Load the resource by ID.
  2. Check authorisation. Compare owner/tenant/role to the authed actor.
  3. Act if allowed. Update / delete / return.

Every CRUD endpoint that operates on a single resource follows this. Memorise it. grit generate resource emits this pattern by default — the bug only appears when humans write handlers and forget step 2.

Defense in depth — the SQL filter

Belt-and-suspenders: scope the QUERY ITSELF by user_id, not just check after loading:

if err := s.db.WithContext(ctx).
Where("user_id = ?", userID).
First(&note, id).Error; err != nil { ... }

Now even if the post-load check is removed by a future refactor, the query can't return another user's note. Two layers; both should fail to leak data.

Test that the fix sticks

apps/api/internal/handlers/note_handler_test.go
func TestUpdateNote_IDORBlocked(t *testing.T) {
s := testServices(t)
a := registerTestUser(t, s, "a@a.com")
b := registerTestUser(t, s, "b@b.com")
note, _ := s.Notes.Create(ctx, a.ID, services.CreateNoteInput{Title: "x", Body: "y"})
_, err := s.Notes.Update(ctx, b.ID, note.ID, services.UpdateNoteInput{Body: ptr("hacked")})
require.ErrorIs(t, err, services.ErrForbidden)
}

Now if a future refactor accidentally removes the check, this test fails and CI blocks the merge.

Quick check

A reviewer says "just return 404 instead of 403 to be more secure". What's the actual security benefit?

Try it

Find and fix an IDOR in your own code:

  1. Search your handlers for any c.Param("id") that's used in a DB query without a corresponding user_id check.
  2. Pick one and write the exploit as curl commands (like this lesson's). Confirm you can read or modify another user's data.
  3. Move the DB call into the service (if not already there). Add the ownership check.
  4. Re-run the exploit. Should now return 403.
  5. Write a unit test that fails if the check is removed.
  6. Commit the fix + test in one commit. Reference the threat from SECURITY.md.

What's next

Next lesson — The authz package. Grit ships an internal/authz helper that centralises this pattern so you write it once, apply it everywhere.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub