How does a user let one app access their data in another app without sharing a password? That is what OAuth 2.0 solves: it is not a token format but a protocol for obtaining a bearer token with the user's consent. In this lesson you will learn the OAuth roles and flows that matter today, how a resource server validates bearer tokens, and how scopes, roles and ownership checks combine into authorization.
OAuth defines four roles: the resource owner (user), the client (app wanting access), the authorization server (issues tokens) and the resource server (your API). Web and mobile apps should use Authorization Code with PKCE, safe even for public clients that cannot keep a secret:
code_verifier and derives code_challenge = base64url(sha256(verifier))./authorize with the challenge, the requested scopes and a random state.redirect_uri with a short-lived code.# Step 4: back-channel exchange
curl -X POST https://auth.example.com/token \
-d grant_type=authorization_code -d code=SplxlOBeZQQYbYS6WxSbIA \
-d redirect_uri=https://app.example.com/callback -d client_id=web-app \
-d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk{ "access_token": "eyJhbGciOiJSUzI1NiIs...", "token_type": "Bearer", "expires_in": 3600,
"refresh_token": "8xLOxBtZp8", "scope": "orders:read orders:write" }A stolen code is useless without the verifier, and state protects the callback against CSRF.
When no user is involved, a backend service uses the Client Credentials grant: POST /token with grant_type=client_credentials, client_id and client_secret, receiving a token for its own identity. Access tokens are short-lived; when one expires, the client sends grant_type=refresh_token for a new one without involving the user. Refresh tokens are long-lived secrets; rotate them on use.
Your API never sees passwords. It receives Authorization: Bearer <token> and validates it locally when the token is a JWT (verify the signature with the authorization server's published JWKS keys, then check exp, iss and aud) or through the introspection endpoint for opaque tokens. A missing or invalid token gets 401; a valid token lacking permission gets 403.
Three questions hide inside "is this allowed?":
// req.auth was populated by JWT verification: { sub, scope: "orders:read", roles: [] }
function requireScope(...needed) {
return (req, res, next) => {
const granted = (req.auth?.scope ?? "").split(" ");
if (!needed.every((s) => granted.includes(s))) {
return res.status(403).json({ error: "insufficient_scope", required: needed });
}
next();
};
}
app.get("/orders/:id", requireScope("orders:read"), async (req, res) => {
const order = await db.orders.findById(req.params.id);
if (!order) return res.status(404).json({ error: "Order not found" });
if (order.customerId !== req.auth.sub && !req.auth.roles?.includes("admin")) {
return res.status(404).json({ error: "Order not found" }); // do not reveal existence
}
res.json(order);
});Skipping the ownership check is the top item in the OWASP API Security Top 10, Broken Object Level Authorization: an attacker just increments the id. Skipping role checks on admin endpoints is its sibling, Broken Function Level Authorization.
orders:write means the app may write orders for this user, not anyone's orders.localStorage instead of the Authorization header.Why does PKCE protect against a stolen authorization code?
401 or 403 precisely.Next lesson: API Versioning Strategies — evolve an API without breaking the clients that already depend on it.