Stage 7 · Master
Phase 3 — User Service
Pagination
Replace OFFSET-based paging with a signed, opaque keyset cursor built on the UUIDv7 primary key, so directory listings stay fast and tamper-resistant at any scale.
OFFSET Gets Slower, Not Just Uglier
Organization Service's pagination lesson already rejected OFFSET for deep, mutable lists; this lesson narrows the idea to directory rows whose UUIDv7 keys sort in creation order without exposing a tenant-wide sequence number.
For user-service the cursor is not a page number and not a timestamp. It is the last visible UUIDv7 from the caller's current organization scope, signed so clients can carry it forward without learning how to forge a different position.
A Cursor Is a Position, Not a Page Number
Because every users.id is a UUIDv7, the id column alone is already time-ordered — no separate created_at tie-break column is required for the common case, though we keep created_at in the response for display. The cursor a client holds is just: 'give me the next N rows with id greater than this one.' We do not hand clients a raw UUID to manipulate, though — we sign it.
A base64-encoded UUID is trivially decodable and editable — a curious client could hand-craft a cursor to probe for the existence of a specific user ID, or attempt to enumerate the table by incrementing values. An HMAC signature over the cursor payload means the server can detect any tampering and reject it, turning the cursor into an opaque token instead of a leaking implementation detail.
package user
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
)
var ErrInvalidCursor = errors.New("cursor is invalid or has been tampered with")
type cursorPayload struct {
OrganizationID uuid.UUID `json:"organization_id"`
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
}
// EncodeCursor produces an opaque, tamper-evident token. The HMAC signature
// means a client can carry this string but can never forge or decode a
// valid one without the server's secret key.
func EncodeCursor(secret []byte, organizationID, id uuid.UUID, createdAt time.Time) (string, error) {
payload, err := json.Marshal(cursorPayload{
OrganizationID: organizationID, ID: id, CreatedAt: createdAt,
})
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, secret)
mac.Write(payload)
sig := mac.Sum(nil)
combined := append(payload, sig...)
return base64.RawURLEncoding.EncodeToString(combined), nil
}
func DecodeCursor(secret []byte, expectedOrganizationID uuid.UUID, token string) (uuid.UUID, time.Time, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(raw) < sha256.Size {
return uuid.UUID{}, time.Time{}, ErrInvalidCursor
}
payload, sig := raw[:len(raw)-sha256.Size], raw[len(raw)-sha256.Size:]
mac := hmac.New(sha256.New, secret)
mac.Write(payload)
expected := mac.Sum(nil)
if !hmac.Equal(sig, expected) {
return uuid.UUID{}, time.Time{}, ErrInvalidCursor
}
var p cursorPayload
if err := json.Unmarshal(payload, &p); err != nil {
return uuid.UUID{}, time.Time{}, ErrInvalidCursor
}
if p.OrganizationID != expectedOrganizationID {
return uuid.UUID{}, time.Time{}, ErrInvalidCursor
}
return p.ID, p.CreatedAt, nil
}hmac.Equal, not ==, compares the signatures — a plain byte comparison would short-circuit on the first mismatched byte, leaking timing information an attacker could use to forge a valid signature one byte at a time.
func TestCursorRejectsCrossOrganizationReplay(t *testing.T) {
orgA, orgB := uuid.New(), uuid.New()
token, err := EncodeCursor([]byte("test-secret"), orgA, uuid.New(), time.Now())
if err != nil {
t.Fatal(err)
}
if _, _, err := DecodeCursor([]byte("test-secret"), orgB, token); !errors.Is(err, ErrInvalidCursor) {
t.Fatalf("cross-organization cursor error = %v; want ErrInvalidCursor", err)
}
}The signature alone proves the payload was issued by this service; the organization comparison proves it was issued for this tenant scope.
The Seek Query Itself
SELECT u.id, u.email, u.first_name, u.last_name, u.status, u.created_at
FROM users u
JOIN memberships m ON m.user_id = u.id
WHERE m.organization_id = $1
AND u.id > $2
ORDER BY u.id ASC
LIMIT $3;Repository construction, pgx error handling, row scanning, and tenant joins were already implemented in Search. The only new implementation is the indexed seek predicate: u.id > $2 jumps to the cursor instead of rescanning and discarding earlier rows.
What Changes Does Keyset Pagination Actually Survive
- A new user created during a paging session never shifts existing pages, because every page is anchored to a remembered id, not a row count.
- A user deleted from an earlier page is simply absent from a later page's results — no duplicate, no skip.
- A cursor built for one organization is rejected before querying if replayed against another: organization_id is inside the signed payload and DecodeCursor compares it to the authenticated request scope.
Applied exercise
Break a naive cursor implementation
A teammate's first draft base64-encodes the raw UUID with no HMAC signature: cursor = base64(id.String()).
- Describe one concrete way a malicious client could exploit the unsigned cursor.
- Explain specifically what hmac.Equal protects against that a plain == comparison would not.
- Decide what HTTP status and error code DecodeCursor's ErrInvalidCursor should map to at the handler layer.
Deliverable
A short writeup naming the exploit, the timing-attack risk, and the chosen error response.
Completion checks
- The exploit describes enumeration or tampering, not just 'it's insecure'.
- The error response uses a 4xx status, not a 5xx — a bad cursor is a client error.
Why does keyset pagination stay fast at page 500 when OFFSET pagination does not?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.