Authentication vs Authorization (With Real Examples)

Authentication answers "who are you?" and authorization answers "what are you allowed to do?". Signing in with a password, a code or Google is authentication; checking that Ada may read invoice 42 but not invoice 43 is authorization. Authentication happens once per session, authorization happens on every request, and most real data leaks come from getting the second one wrong.

#Side by side

Authentication (authn)Authorization (authz)
QuestionWho is this?May they do this?
InputPassword, code, passkey, OAuth, SSOIdentity plus a rule, a role or ownership
OutputA session or tokenAllow or deny
WhenAt sign-in (and refresh)On every request, for every resource
Typical failureWeak passwords, stolen sessionsUser A reads user B's data
HTTP status when it fails401 Unauthorized403 Forbidden (or 404 to hide existence)
Where it livesAuth serviceYour API, your database, or both

The HTTP naming is confusing for historical reasons: 401 Unauthorized actually means "not authenticated" (send credentials), and 403 Forbidden means "authenticated, but not allowed". Use them that way and clients can react correctly: 401 means sign in again, 403 means show "you do not have access".

#A request, step by step

Follow one API call: Ada opens invoice 42 in a billing app.

  1. Authenticate. Earlier, Ada signed in. The auth server gave her app an access token, a signed JWT that says sub: <Ada's id>, role: authenticated and expires in an hour.
  2. Present the token. The app sends GET /invoices/42 with Authorization: Bearer <token>.
  3. Verify the token. The server checks the signature and expiry. If either fails: 401. This is still authentication: it proves the token is real and identifies Ada.
  4. Authorize. The server asks: is invoice 42 Ada's, or is she an admin of the company that owns it? If not: 403 (or 404).
  5. Respond. Only now does the data leave the server.

Steps 1 to 3 are usually handled by a library or service. Step 4 is your application logic, and it must run for every resource, not just every route. More on this in JWT authentication explained.

#Authentication vs authorization in an API: three real examples

#Example 1: ownership in route code

The classic way, in an Express or Next.js route:

JavaScript
app.get('/invoices/:id', requireUser, async (req, res) => {
  const invoice = await db.invoice.findUnique({ where: { id: req.params.id } })
  if (!invoice) return res.status(404).end()
  if (invoice.userId !== req.user.id) return res.status(403).end() // authorization
  res.json(invoice)
})

requireUser is authentication. The userId comparison is authorization. Delete that one line and any signed-in user can read any invoice by changing the number in the URL. This bug class has a name, Insecure Direct Object Reference (IDOR), also called broken object level authorization.

#Example 2: the same rule in the database

With Postgres Row Level Security, the rule lives next to the data and runs on every query, from every client:

SQL
create table public.invoices (
  id bigint generated always as identity primary key,
  user_id uuid not null default auth.uid() references auth.users on delete cascade,
  total_cents int not null
);
alter table public.invoices enable row level security;

create policy "owners read their invoices" on public.invoices
  for select to authenticated
  using (user_id = auth.uid());

auth.uid() reads the sub claim from the verified token. Now this query returns only Ada's rows, whatever id she asks for:

JavaScript
const { data } = await stitchdb.from('invoices').select('*').eq('id', 43)
// data is [] for Ada if invoice 43 is not hers

On StitchDB every request runs as a database role chosen from its key and token: anon without a session, authenticated with one, and service_role for trusted servers (which bypasses policies) (roles and JWT). Policies are the authorization layer; see row level security.

#Example 3: roles and teams

Ownership is not enough for team apps. Ada should see every invoice of her company, and only admins may edit. StitchDB Organizations adds members, roles and helpers for policies. With an org_id uuid column on the table:

SQL
create policy "members read" on public.invoices
  for select to authenticated
  using (auth.is_member(org_id));

create policy "admins write" on public.invoices
  for all to authenticated
  using (auth.has_role('admin', org_id))
  with check (auth.has_role('admin', org_id));

The helpers look membership up live, so removing someone from the team takes effect at once, even if their token is still valid. That matters: a token is a snapshot of who someone was when it was issued. Authorization should check current facts where it can.

#Where authentication ends and authorization begins

Some features sit on the line. A few that confuse people:

MFA. A second factor is authentication, but requiring it for some data is authorization. On StitchDB a session that passed a second factor carries aal: 'aal2', and a restrictive policy can demand it:

SQL
create policy "MFA required" on public.payouts
  as restrictive for all to authenticated
  using ((auth.jwt() ->> 'aal') = 'aal2');

See multi-factor authentication.

Scopes in OAuth. When an app asks Google for "read your calendar", the user is authorizing that app. OAuth is an authorization protocol; OpenID Connect adds authentication on top.

API keys. A key identifies a calling app (authentication of the client), not a user. The StitchDB anon key only proves the request comes from your app; it gives no access beyond what policies allow to anon. The service_role key is different: it skips policies, so it must never ship in a browser or mobile app.

Claims in the token. Putting role: admin in a JWT is convenient, but it is only as fresh as the token. Claims set by the server (app_metadata on StitchDB) are safe to use for authorization. Claims the user can edit (user_metadata) are not. To add your own claims, such as a tenant id, use the custom access token auth hook.

#Common mistakes

  1. Checking the route, not the resource. "Only signed-in users can open /invoices" is authentication. You still need "only this invoice's owner".
  2. Hiding buttons instead of refusing requests. If the API accepts the call, a hidden button protects nothing.
  3. Trusting client-sent ids. Never take user_id from the request body. Use the id from the verified token, or a column default of auth.uid().
  4. Forgetting writes. A select policy does not cover insert, update or delete. Use with check for writes.
  5. Using the admin key everywhere. A server that always uses a key that bypasses rules has no authorization at all, only your if statements.
  6. Long-lived tokens with roles baked in. A demoted admin keeps admin rights until the token expires. Keep access tokens short (StitchDB defaults to one hour) or check roles live.

#A checklist for your API

  • Every endpoint returns 401 without a valid token and 403 (or 404) for resources the caller may not touch.
  • Ownership or membership is checked per resource, ideally in the database.
  • Row Level Security is on for every table exposed to clients.
  • Roles come from server-controlled data, not user-editable fields.
  • Sensitive actions require a fresh or stronger authentication (MFA, re-authentication).
  • Admin keys live only on servers and in jobs.

For the authentication half of the checklist, see authentication best practices.

#FAQ

#What is the difference between authentication and authorization in simple terms?

Authentication is showing your ID at the door. Authorization is the list of rooms your badge opens.

#Which comes first?

Authentication. You cannot decide what someone may do until you know who they are. Visitors without a session are a special case: on StitchDB their requests run as the anon role, and policies decide which public data that role may see.

#Should my API return 403 or 404 for someone else's resource?

Either is fine. 404 hides that the resource exists, which is often better for private data. Row Level Security naturally behaves like 404: the row is simply not returned.

#Is OAuth authentication or authorization?

OAuth 2.0 is for authorization (granting an app access). OpenID Connect, built on OAuth, adds authentication. "Sign in with Google" uses both.

#Can I do authorization only in the frontend?

No. Anything in the frontend can be changed by the user. The server or the database has to refuse.

Put authorization next to your data: read row level security and create a free project.