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) | |
|---|---|---|
| Question | Who is this? | May they do this? |
| Input | Password, code, passkey, OAuth, SSO | Identity plus a rule, a role or ownership |
| Output | A session or token | Allow or deny |
| When | At sign-in (and refresh) | On every request, for every resource |
| Typical failure | Weak passwords, stolen sessions | User A reads user B's data |
| HTTP status when it fails | 401 Unauthorized | 403 Forbidden (or 404 to hide existence) |
| Where it lives | Auth service | Your 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.
- Authenticate. Earlier, Ada signed in. The auth server gave her app an access token, a signed JWT that says
sub: <Ada's id>, role: authenticatedand expires in an hour. - Present the token. The app sends
GET /invoices/42withAuthorization: Bearer <token>. - 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.
- 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).
- 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:
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:
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:
const { data } = await stitchdb.from('invoices').select('*').eq('id', 43)
// data is [] for Ada if invoice 43 is not hersOn 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:
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:
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
- Checking the route, not the resource. "Only signed-in users can open /invoices" is authentication. You still need "only this invoice's owner".
- Hiding buttons instead of refusing requests. If the API accepts the call, a hidden button protects nothing.
- Trusting client-sent ids. Never take
user_idfrom the request body. Use the id from the verified token, or a column default ofauth.uid(). - Forgetting writes. A
selectpolicy does not coverinsert,updateordelete. Usewith checkfor writes. - Using the admin key everywhere. A server that always uses a key that bypasses rules has no authorization at all, only your
ifstatements. - 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.