DEV Community

Cover image for Your App Has Login. That Doesn’t Mean Your Users’ Data Is Safe.
Robert Adamson
Robert Adamson

Posted on

Your App Has Login. That Doesn’t Mean Your Users’ Data Is Safe.

Your app has login.

Passwords are hashed. Tokens are validated. Private pages redirect unauthenticated visitors.

Everything looks protected.

Then a logged-in user changes this request:

GET /api/invoices/1042
Enter fullscreen mode Exit fullscreen mode

To this:

GET /api/invoices/1043
Enter fullscreen mode Exit fullscreen mode

And receives another customer’s invoice.

The login system worked.

The access control did not.

This is one of the easiest mistakes to overlook because developers usually test their app with their own account—and their own data.

Here’s how to catch it.

Authentication answers only the first question

Authentication asks:

Who is making this request?

Authorization asks:

Is this person allowed to perform this action on this resource?

A valid session answers the first question.

It does not automatically answer the second.

A user might be allowed to view one invoice but not another. A staff member might be allowed to read a repair ticket but not delete it. An account owner might manage their own business without having access to every business on the platform.

“Logged in” is a starting condition, not a complete permission policy.

The dangerous query looks completely normal

Consider this simplified Express-style endpoint:

app.get("/api/invoices/:id", requireAuth, async (req, res) => {
  const invoice = await db.invoice.findUnique({
    where: { id: req.params.id },
  });

  if (!invoice) {
    return res.sendStatus(404);
  }

  return res.json(invoice);
});
Enter fullscreen mode Exit fullscreen mode

There is authentication middleware.

There is a database query.

There is even a missing-record check.

But nothing connects the invoice to the authenticated user.

The endpoint effectively says:

If you can log in and identify an invoice, you can read it.

That is usually not the intended rule.

Put the access boundary into the query

For a simple app where each invoice belongs directly to one user, scope the lookup:

app.get("/api/invoices/:id", requireAuth, async (req, res) => {
  const invoice = await db.invoice.findFirst({
    where: {
      id: req.params.id,
      ownerId: req.user.id,
    },
  });

  if (!invoice) {
    return res.sendStatus(404);
  }

  return res.json({
    id: invoice.id,
    total: invoice.total,
    status: invoice.status,
  });
});
Enter fullscreen mode Exit fullscreen mode

These database calls are illustrative; adapt them to your ORM and schema.

The important change is the condition:

ownerId: req.user.id
Enter fullscreen mode Exit fullscreen mode

Here, req.user.id must come from verified authentication—not a user ID supplied in the request body.

The query now asks for an invoice that this user owns.

Returning selected fields also helps avoid exposing internal properties the client does not need.

Business accounts need membership checks

Direct ownership is not enough for every app.

A repair shop, project workspace, or scheduling business may have several members sharing records.

In that case, the access rule might be:

The requester must be an active member of the account, with permission to perform this action.

A safe flow is:

  1. Identify the authenticated user.
  2. Verify their membership in the requested account.
  3. Check the permission required for the action.
  4. Query the resource within that verified account.

Conceptually:

const membership = await findActiveMembership({
  userId: req.user.id,
  accountId: req.params.accountId,
});

if (!membership || !can(membership, "invoice:read")) {
  return res.sendStatus(403);
}

const invoice = await findInvoice({
  id: req.params.invoiceId,
  accountId: membership.accountId,
});
Enter fullscreen mode Exit fullscreen mode

An account ID in a URL, header, or request body is a selection—not proof of access.

Verify the relationship before trusting the account context.

Also make sure revoked memberships stop granting access according to your application’s requirements. A permission stored in a long-lived token can become stale.

Hiding a button does not protect the endpoint

You hide the Delete button from ordinary staff.

Good. The interface should reflect their permissions.

But someone can still send a request directly:

DELETE /api/invoices/1042
Enter fullscreen mode Exit fullscreen mode

Your backend must independently enforce the rule.

The same applies to:

  • Hidden admin pages.
  • Disabled form fields.
  • Frontend route guards.
  • Dropdowns that display only permitted accounts.

Those controls improve the user experience. The server still decides what is allowed.

UUIDs do not replace authorization

Sequential IDs make the example easy to demonstrate.

But switching to UUIDs does not solve the underlying problem.

Identifiers can appear in shared links, browser history, logs, exports, or other API responses.

If knowing an identifier is enough to access private data, the permission boundary is still missing.

Use whatever identifier format fits your system.

Access should depend on permission, not on the hope that nobody discovers the ID.

Check writes as carefully as reads

Reading another person’s invoice is bad.

Editing it, deleting it, or moving it to another account can be worse.

Scope write operations too:

const result = await db.invoice.updateMany({
  where: {
    id: req.params.id,
    ownerId: req.user.id,
  },
  data: {
    notes: validatedInput.notes,
  },
});

if (result.count === 0) {
  return res.sendStatus(404);
}
Enter fullscreen mode Exit fullscreen mode

For shared accounts, use the verified account scope and check the required write permission.

Also avoid passing the entire request body into your database:

// Dangerous if sensitive fields are accepted:
data: req.body
Enter fullscreen mode Exit fullscreen mode

A client could include fields you never intended them to change, such as:

{
  "ownerId": "another-user",
  "accountId": "another-account",
  "status": "paid"
}
Enter fullscreen mode Exit fullscreen mode

Validate input and explicitly allow the fields that operation supports.

Ownership, permissions, and payment status deserve deliberate rules.

Test with two accounts

One account can prove that the happy path works.

Two accounts can expose a missing access boundary.

Create:

  • User A with a private invoice.
  • User B with a different private invoice.

Then test:

  1. A can read A’s invoice.
  2. B cannot read A’s invoice.
  3. B cannot edit A’s invoice.
  4. B cannot delete A’s invoice.
  5. B’s list, search, and export endpoints do not include A’s invoice.

For business workspaces, add tests for different roles, revoked memberships, and users belonging to multiple accounts.

Check the actual outcome—not only the HTTP status.

After a denied update, verify that the record did not change. After a denied delete, verify that it still exists.

For inaccessible resources, returning 404 can help avoid revealing whether a particular record exists. 403 can also be appropriate. Choose a consistent policy, but enforce the access check either way.

Don’t forget the quieter endpoints

Developers often protect the detail page and miss another path to the same data.

Review:

  • Search results.
  • CSV and PDF exports.
  • File downloads.
  • Bulk update endpoints.
  • Dashboard totals.
  • Nested resources and attachments.

A private invoice is not protected if its PDF download is public.

A correctly scoped detail endpoint does not fix an unscoped export.

A useful prompt for your AI assistant

Review these endpoints for authorization gaps.

Do not modify code yet.

For each operation:
1. Identify the authenticated user.
2. Identify the requested resource.
3. Explain the required ownership, membership,
   and role checks.
4. Show where the database query enforces that scope.
5. Identify client-controlled fields that could
   change ownership or permissions.
6. Check related search, export, and download paths.

Propose tests using two users in different accounts.

State missing evidence.
Do not assume authentication middleware
also enforces resource authorization.
Enter fullscreen mode Exit fullscreen mode

Use the response as a review plan. Inspect the code and run the tests before accepting its conclusions.

The question to ask before shipping

Don’t stop at:

“Can a user log in?”

Ask:

“Can this user access only the records and actions they are allowed to access?”

Login protects the entrance.

Authorization protects the data behind it.

When did you last test your app with two different user accounts?

Top comments (1)

Collapse
 
sinarezaei profile image
Sina Rezaei •

One case that can slip through even when the API endpoints look properly protected is shared caching in a multi-tenant SaaS app. Imagine two companies requesting the same dashboard endpoint, but the Redis cache key is built from the resource ID alone, without the tenant context. If authorization only happens on a cache miss, the second company could receive the first company's data without ever hitting the database.

I'd test this by warming the cache as Tenant A, then repeating the exact request as Tenant B, including after a role change or revoked membership. Authorization needs to hold on cache hits too, not just in the database query. That's the kind of gap a happy-path test with one account will never catch.