Multi-Tenant: Each User Their Own Data Slice, and Blocking IDOR

K
Kai··5 min read

After the previous article, every link is tied to a real owner via the sub claim. But tying an owner isn't enough; the system still doesn't partition data by owner. User A must see only A's links, and more importantly, A must not be able to edit or delete B's links even if A guesses the code right. This article builds exactly those two constraints, and checks them with two real users.

Goal

Add GET /links to list links scoped to the identity in the token, and DELETE /links/{code} to check ownership right inside the delete operation to block the IDOR vulnerability. Test with two users A and B to confirm each sees only their own data and can't touch the other's. Still using the existing stack, with negligible cost.

What multi-tenant means here

A multi-tenant system serves many users on the same infrastructure, and each user can only see and operate on their own slice of data. For a URL shortener, a "tenant" is each Cognito user. There are two sides to guarantee: reads are scoped (listing shows only your own links), and writes are scoped (edit/delete only affect your own links). Both must rest on the identity the system has authenticated, not on a value the client sends up.

The second point that has to be right: a common mistake is taking ownerId from the body or query string and trusting it. When you do that, anyone can set ownerId to someone else. The correct approach is to trust only the sub claim in the token that API Gateway has already authenticated, something the client can't forge.

Listing by token identity

The GSI1 built in Article 05 lets you query links by USER#<ownerId>. The list handler takes owner from the claim, then queries exactly that partition on the index:

const owner = event.requestContext.authorizer?.jwt?.claims?.sub as string;
if (!owner) return json(401, { error: "thieu danh tinh nguoi dung" });

const res = await ddb.send(
  new QueryCommand({
    TableName: TABLE,
    IndexName: "GSI1",
    KeyConditionExpression: "GSI1PK = :u",
    ExpressionAttributeValues: { ":u": `USER#${owner}` },
    ScanIndexForward: false,
  })
);

The isolation comes from GSI1PK always being built from the owner taken in the token, never accepted from anywhere else. A user has no way to request a listing of someone else's links, because the query value is bound to the authenticated identity. This route is attached to Authorizer: CognitoAuthorizer, so the claim is always present.

   GET /links + Bearer <token A>
        │
        ▼
   API Gateway authenticates token ─▶ claims.sub = A
        │
        ▼
   Lambda queries GSI1 with GSI1PK = USER#A   (bound to identity, client can't change it)
        │
        ▼
   returns only A's links

A note on routing: we have both GET /links (list) and GET /{code} (open link). They don't collide because API Gateway prioritizes an exact-match route over a variable route, so /links goes to the list handler while /aK9xQ2z goes to the open-link handler.

IDOR and how to block it in the data layer

IDOR (Insecure Direct Object Reference) is a vulnerability where one person can access another's resource just by changing the identifier in the request. For a URL shortener, that's B calling DELETE /links/<A's code> and deleting A's link. Logging in does not by itself block this; B is still a valid user, just touching a resource that isn't theirs.

The most solid way to block it is to check ownership right inside the write operation, with a ConditionExpression, so there's no gap between the check and the delete:

await ddb.send(
  new DeleteCommand({
    TableName: TABLE,
    Key: linkKey(code),
    ConditionExpression: "attribute_exists(PK) AND ownerId = :me",
    ExpressionAttributeValues: { ":me": owner },
  })
);

The condition only allows the delete when the item exists and its ownerId equals the caller. If the link doesn't exist, or exists but belongs to someone else, the condition fails and DynamoDB throws ConditionalCheckFailedException. The handler catches that and returns 404 for both cases:

if ((err as { name?: string }).name === "ConditionalCheckFailedException") {
  return json(404, { error: "link khong ton tai hoac khong thuoc ve ban" });
}

Returning 404 instead of 403 is deliberate. If you return 403 ("forbidden") for someone else's link and 404 ("not found") for a nonexistent link, an attacker can distinguish which codes exist in the system. Returning 404 for both makes the two cases indistinguishable from the outside, leaking nothing about the existence of a resource.

Trying it with two users

Create two users A (alice) and B (bob), get each one's token, then each creates a link. A lists links, sees only its own:

$ curl -s "$API/links" -H "Authorization: Bearer $TOKEN_A"
{"links":[{"code":"465KnY7","target":"https://alice.example/page","clicks":0,"createdAt":"2026-05-25T16:51:06.895Z"}]}

B lists, also sees only its own, not A's link:

$ curl -s "$API/links" -H "Authorization: Bearer $TOKEN_B"
{"links":[{"code":"t08fg30","target":"https://bob.example/page","clicks":0,"createdAt":"2026-05-25T16:51:07.744Z"}]}

The main test is here: B knows A's link code (465KnY7) and tries to delete it:

$ curl -s -X DELETE "$API/links/465KnY7" -H "Authorization: Bearer $TOKEN_B"
{"error":"link khong ton tai hoac khong thuoc ve ban"}

B gets a 404, and A's link is still intact in the table:

$ aws dynamodb get-item --table-name url-shortener \
    --key '{"PK":{"S":"LINK#465KnY7"},"SK":{"S":"META"}}' --query 'Item.ownerId.S'
"f9cad55c-..."

IDOR is blocked right at the data layer. And A deleting its own link succeeds:

$ curl -s -X DELETE "$API/links/465KnY7" -H "Authorization: Bearer $TOKEN_A"
{"deleted":"465KnY7"}

The same DELETE request on the same code, with completely different outcomes depending on the caller. The difference isn't in the code path but in the ownerId = :me condition comparing the token identity against the item's real owner.

🧹 Cleanup

aws dynamodb delete-item --table-name url-shortener \
  --key "{\"PK\":{\"S\":\"LINK#$CODE_B\"},\"SK\":{\"S\":\"META\"}}"
aws cognito-idp admin-delete-user --user-pool-id "$POOL" --username alice@example.com
aws cognito-idp admin-delete-user --user-pool-id "$POOL" --username bob@example.com

Keep the stack, user pool, and table for later parts.

Wrap-up

The URL shortener is now truly multi-tenant. Listing links is bound to the identity in the token so each person sees only their own data, and deleting a link checks ownership right inside the operation via ConditionExpression so one person can't touch another's link even if they guess the code right. Return 404 for both "not found" and "not yours" so nothing about existence leaks. The core of both is trusting only the identity the system has authenticated, not values from the client.

The core and the users parts are done at this point. The next article opens Part IV, the event-driven part, where the product starts to get interesting: every link open will publish an event onto EventBridge, decoupling click recording from the redirect path. We'll build a custom event bus, define event filtering rules, and lay the foundation for realtime analytics in the following articles.