IDOR — the most common bug in the wild
Cross-account access by guessing IDs.
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
The vulnerable code
func (h *NoteHandler) Update(c *gin.Context) {id, _ := strconv.ParseUint(c.Param("id"), 10, 64)var in services.UpdateNoteInputc.ShouldBindJSON(&in)// 🚨 BUG: no check that the note belongs to the authed uservar note models.Noteh.db.First(¬e, id)h.db.Model(¬e).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
idmatches the URL, regardless of ownership. - The handler doesn't even check whether the row exists before updating.
Exploit it yourself
# 1. Register User Acurl -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 tokenTOKEN_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 notecurl -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 notecurl -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
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.Noteif err := s.db.WithContext(ctx).First(¬e, id).Error; err != nil {if errors.Is(err, gorm.ErrRecordNotFound) { return note, ErrNotFound }return note, err}// 🔒 Ownership check — the fixif 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(¬e).Updates(updates).Error; err != nil {return note, err}return note, nil}
Three lines of fix:
- Load the note first.
- Compare
note.UserIDto the authed user. - Return
ErrForbiddenif 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.
The pattern for ALL access control
- Load the resource by ID.
- Check authorisation. Compare owner/tenant/role to the authed actor.
- 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(¬e, 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
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
Try it
Find and fix an IDOR in your own code:
- Search your handlers for any
c.Param("id")that's used in a DB query without a corresponding user_id check. - Pick one and write the exploit as curl commands (like this lesson's). Confirm you can read or modify another user's data.
- Move the DB call into the service (if not already there). Add the ownership check.
- Re-run the exploit. Should now return 403.
- Write a unit test that fails if the check is removed.
- 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