Part 4 added requireAuth, so everything behind it now comes from someone who proved they know a password. That answers who is calling. It doesn’t answer the question that actually protects the data: is this particular note theirs?
Two questions, not one
Authentication is “who are you?” Authorization is “what are you allowed to touch?” A lot of tutorials stop once the login form works, and it’s easy to assume that covers both. It doesn’t. requireAuth says a valid session exists and nothing more. Skip the second check and you get an API where any logged-in user can read anyone’s notes by guessing an ID.
Getting the user out of the context
requireAuth already puts the caller’s ID in the request context. Handlers read it back with a small helper next to the middleware:
func userIDFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(userIDKey).(uuid.UUID)
return id, ok
}
This is the only place a handler should get a user ID from. Never from the URL and never from the JSON body, because the client controls both and can put anything there. The context value exists because the middleware checked a real session.
List and create
With that helper, the two handlers Part 4 already routed to are short:
func (a *api) listNotes(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFromContext(r.Context())
if !ok {
http.Error(w, "not authenticated", http.StatusUnauthorized)
return
}
notes, err := a.q.ListNotesByUser(r.Context(), userID)
if err != nil {
http.Error(w, "could not load notes", http.StatusInternalServerError)
return
}
if notes == nil {
notes = []database.Note{} // encode as [] instead of null
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(notes)
}
func (a *api) createNote(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFromContext(r.Context())
if !ok {
http.Error(w, "not authenticated", http.StatusUnauthorized)
return
}
var in struct {
Body string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
note, err := a.q.CreateNote(r.Context(), database.CreateNoteParams{
UserID: userID,
Body: in.Body,
})
if err != nil {
http.Error(w, "could not create note", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(note)
}
These use the same ListNotesByUser and CreateNote queries sqlc generated in Part 2. The SQL didn’t change. What changed is that userID now comes from a verified session. A list of “my notes” and a create that always writes “my” ID can’t reach anyone else’s data, so these two were never the dangerous ones.
The handler that needs a real check
Fetching one note by ID is where it goes wrong. Here’s a version that looks fine and isn’t:
// Don't do this. It checks that the note exists, not that it's yours.
func (a *api) getNote(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
note, err := a.q.GetNoteByID(r.Context(), id)
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "note not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "could not load note", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(note)
}
It compiles, it works when you try it with your own notes, and it gives every logged-in user every other user’s notes, because GetNoteByID has no idea who’s asking.
The fix isn’t an if note.UserID != userID bolted on afterwards. Put the owner in the query so it can’t return the wrong row in the first place:
-- name: GetNoteForUser :one
SELECT * FROM notes
WHERE id = $1 AND user_id = $2;
-- name: DeleteNoteForUser :execrows
DELETE FROM notes
WHERE id = $1 AND user_id = $2;
:execrows is the sqlc annotation for “I only care how many rows this touched”. The generated method returns (int64, error). After regenerating, both handlers do the ownership check in the same query that fetches or deletes:
func (a *api) getNote(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFromContext(r.Context())
if !ok {
http.Error(w, "not authenticated", http.StatusUnauthorized)
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
note, err := a.q.GetNoteForUser(r.Context(), database.GetNoteForUserParams{
ID: id,
UserID: userID,
})
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "note not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "could not load note", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(note)
}
func (a *api) deleteNote(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFromContext(r.Context())
if !ok {
http.Error(w, "not authenticated", http.StatusUnauthorized)
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
rows, err := a.q.DeleteNoteForUser(r.Context(), database.DeleteNoteForUserParams{
ID: id,
UserID: userID,
})
if err != nil {
http.Error(w, "could not delete note", http.StatusInternalServerError)
return
}
if rows == 0 {
http.Error(w, "note not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
Register them next to the others, behind the same middleware:
mux.Handle("GET /notes/{id}", a.requireAuth(http.HandlerFunc(a.getNote)))
mux.Handle("DELETE /notes/{id}", a.requireAuth(http.HandlerFunc(a.deleteNote)))
When a DELETE touches zero rows, either the note never existed or it exists and belongs to someone else. Both get the same 404. Updating a note works the same way: an UpdateNoteForUser :execrows query that filters on id and user_id.
Why 404 and not 403
The obvious status for someone else’s note is 403 Forbidden. I use 404 on purpose. A 403 tells the caller the note exists, and anyone who can tell the two responses apart can walk through IDs and learn which ones are real without ever reading a note. Answering 404 to both “doesn’t exist” and “isn’t yours” gives away nothing, which is all the caller is entitled to know anyway.
It’s the same idea as the identical 401 for a wrong password and an unknown email in Part 4. The more specific error is nicer for the client and also nicer for an attacker.
Testing it with two users
Part 3’s tests used one user at a time. Ownership needs two:
package database_test
import (
"context"
"errors"
"testing"
"github.com/jackc/pgx/v5"
"github.com/natnael-alemayehu/notes-api/internal/database"
)
func TestGetNoteForUser_HidesOtherUsersNotes(t *testing.T) {
pool := setupDB(t)
q := database.New(pool)
ctx := context.Background()
alice, err := q.CreateUser(ctx, database.CreateUserParams{
Email: "alice@example.com", Name: "Alice", PasswordHash: "x",
})
if err != nil {
t.Fatalf("create alice: %v", err)
}
bob, err := q.CreateUser(ctx, database.CreateUserParams{
Email: "bob@example.com", Name: "Bob", PasswordHash: "x",
})
if err != nil {
t.Fatalf("create bob: %v", err)
}
note, err := q.CreateNote(ctx, database.CreateNoteParams{UserID: alice.ID, Body: "Alice's note"})
if err != nil {
t.Fatalf("create note: %v", err)
}
// Bob asking for Alice's note should look the same as asking for a note
// that doesn't exist.
_, err = q.GetNoteForUser(ctx, database.GetNoteForUserParams{ID: note.ID, UserID: bob.ID})
if !errors.Is(err, pgx.ErrNoRows) {
t.Fatalf("expected no rows for bob, got %v", err)
}
got, err := q.GetNoteForUser(ctx, database.GetNoteForUserParams{ID: note.ID, UserID: alice.ID})
if err != nil {
t.Fatalf("get own note: %v", err)
}
if got.ID != note.ID {
t.Errorf("got note %v, want %v", got.ID, note.ID)
}
}
This tests the query, which is where the check now lives. It wouldn’t catch someone writing a new handler that calls an unscoped query like GetNoteByID. For that you’d want a handler-level test with httptest and two logged-in clients, and I’d add one for every route that takes an ID. The useful habit is writing the test from the attacker’s side. A test that only ever plays the honest user passes happily against the broken version.
Next up
Sessions and ownership checks hold up until a token leaks, through a browser extension that reads cookies it shouldn’t or a log line that captured a header it shouldn’t have. Part 6 limits the damage by rotating the session ID itself.