josiete.com

Authentication vs. authorization: OAuth 2.0, OpenID Connect, and SAML explained with examples

Illustration of an identity receiving access to a gallery, with permission to edit an owned photograph and an unrelated photograph protected

A photo application is a useful place to separate concepts that are often blended together. Imagine that Alice signs in, browses a gallery, and changes the title of a photograph she uploaded. She can see one of Bob’s photographs, but cannot edit it. What did each step establish?

The sign-in established Alice’s identity. The decision to edit each photograph applied rules to an action and a specific resource. OAuth 2.0, OpenID Connect (OIDC), and SAML solve different parts of that problem. Keeping their boundaries clear avoids two common mistakes: treating a token as unlimited authorization, and treating an identity assertion as a credential for every API.

Authentication and authorization are not synonyms

Authentication answers “who are you?” A password, passkey, corporate session, or authenticator app can let a provider establish that the person is Alice.

Authorization answers “what can you do, to which resource, and under which conditions?” Alice might have photos:read and photos:write, while the API must still decide whether she owns photograph p-42, whether it is archived, or whether a team policy grants access. Being authenticated does not make Alice the owner of every photograph.

The distinction also appears in HTTP:

  • 401 Unauthorized—despite its historical name—means valid credentials are missing or could not be validated. A Bearer scheme commonly includes WWW-Authenticate.
  • 403 Forbidden means that the request was understood and the credentials were valid, but application authorization forbids the operation. Some APIs deliberately use 404 instead to avoid revealing that a resource exists.

The exact error contract should be documented. Using 401 for an expired access token or a wrong audience, and 403 for insufficient scope, role, or ownership gives clients and operators a helpful signal. MDN’s HTTP documentation distinguishes the two.

OAuth 2.0: delegated access, not a complete permission system

OAuth 2.0 lets an application gain limited access to a resource without receiving the person’s password. In this case, the gallery web application never receives Alice’s password: she enters it, when required, at the identity provider. The application gets a token it can present to the photo API.

Its four roles describe the journey:

  • the resource owner is Alice, the person entitled to the photos or the access decision;
  • the client is the web application that wants to call the API;
  • the authorization server authenticates Alice, collects consent when appropriate, and issues credentials;
  • the resource server is the photo API that protects the resources and validates the received credential.

Consent, scopes, and API authorization are related but separate layers. Consent can let a client ask for photos:read; a scope limits the delegated access requested or granted; and the API decides on every request whether the token, action, and resource satisfy its policy. In particular, photos:write does not mean “edit any photograph”: the API must compare the subject in the token with the owner of p-42, or query a policy that permits a team editor.

OAuth defines how an access token is obtained and used, not a universal business-permission model. A token can be opaque or a JWT, and contain scopes, roles, or provider-specific attributes. Do not design a business policy assuming every provider represents those facts in the same way. Start with OAuth 2.0 and its security BCP.

OpenID Connect adds “who signed in?”

OAuth alone handles delegated authorization: an application may be allowed to invoke a resource without that being a sufficient identity statement for the application itself. OIDC adds an authentication layer on OAuth 2.0 and standardizes discovery, claims, and the ID token. It is not the historical OpenID 2.0 protocol; the names are similar, but the technologies are different.

An OIDC flow can return three distinct artifacts:

ArtifactMain recipientPurpose
ID tokenThe clientStates that the provider authenticated a user and carries claims such as iss, sub, and aud, plus requested profile data.
Access tokenThe API or resource serverIs presented as Authorization: Bearer … to access protected resources.
Refresh tokenThe authorized client, when grantedLets it request new access tokens in certain circumstances.

An ID token is intended for the client that initiated the sign-in; do not send it to the API as a credential. Both artifacts may look like JWTs, but their intended audiences, claims, and security contracts differ. OIDC Core requires validation of, among other things, issuer, audience, signature, and expiry for ID tokens.

A modern sign-in: Authorization Code with PKCE

For a new browser application, use Authorization Code with PKCE. The browser receives a short-lived code through the redirect and the client exchanges it at the token endpoint. PKCE binds that exchange to the instance that initiated it through an ephemeral code_verifier; only its derived challenge, normally using S256, is sent to the authorization server.

This is a plain-text diagram, so it needs no JavaScript or special site renderer:

Browser / SPA             Authorization server                 Photo API
       |                         |                                  |
       |-- /authorize ---------->|                                  |
       |   response_type=code, state, nonce,                         |
       |   code_challenge=S256(verifier), scope                      |
       |<-- login and consent ---------------------------------------|
       |<-- redirect_uri?code=...&state=... --|                      |
       |                         |                                  |
       |-- /token: code + code_verifier ---->|                      |
       |<-- access_token, id_token (maybe refresh_token) -----------|
       |                                                           |
       |-- GET /photos/p-42, Authorization: Bearer access_token --->|
       |<-- 200, 403, or 401 ----------------------------------------|

The random values are not interchangeable:

  • state binds the authorization response to the browser session and transaction that started it; it mitigates CSRF and can carry navigation state protected against tampering.
  • nonce binds an OIDC request to the returning ID token and helps detect replay of that authentication assertion. The client must check it.
  • PKCE binds the code to the client instance’s secret code_verifier, so a person who steals the code cannot redeem it without that value.

The OAuth security BCP requires PKCE for public clients and recommends S256; current browser guidance also favours Authorization Code with PKCE. Implicit and direct password exchange are not recommended choices for a new application: the former puts tokens in browser navigation, and the latter makes a client handle credentials that should be handled by the provider.

SAML: enterprise federation and SSO

SAML 2.0 is common in enterprise federation. A company can have one corporate Identity Provider (IdP)—such as an employee directory with MFA—and several Service Providers (SPs): payroll, intranet, support, and an internal gallery. When Alice opens the gallery, its SP redirects her to the IdP; because she already has an IdP SSO session, it returns a signed assertion that establishes her identity and, depending on configuration, attributes or groups.

Alice therefore does not enter her password at every application. The SP validates issuer, recipient, signature, audience, time conditions, and response correlation before creating its own application session. Subsequent requests can carry that session cookie; a SAML exchange need not occur on every page request.

SAML 2.0OpenID Connect
Common format and environmentXML assertions; broadly deployed for enterprise web SSO.JSON/JWT and OAuth endpoints; natural for web, mobile, and APIs.
Typical roleIdP–SP federation and application session.Client authentication and delegated API authorization.
Practical choiceA good fit when the organization or SP already uses SAML.A good fit for modern clients and OAuth APIs.

Neither universally replaces the other. Existing integrations, SaaS products, the session model, and API needs drive the decision. SAML 2.0 Core defines assertions; OIDC covers a different family of exchanges and profiles.

Do we have to ask who you are on every request?

Not necessarily. Separate initial-login traffic—redirects, authentication, consent, and code exchange—from ordinary API calls. The latter should not make the authorization server an unnecessary synchronous dependency, though every design has a trade-off.

An access token can be a signed JWT. The API downloads the issuer’s public keys from JWKS, caches them, and verifies the signature, an allowed algorithm, iss, aud, and exp locally. When an unknown kid arrives, a correct library fetches JWKS again to allow key rotation. After the cache is warm, latency is local and the API can validate during a brief provider outage, but revocation does not automatically affect an already issued JWT.

It can instead be an opaque token, a reference without meaning to the API. The API authenticates itself to the introspection endpoint and receives, for example, active: true. That can reflect revocation quickly, at the cost of authorization-server latency, availability, and load. Positive or negative responses can be cached, but the cache lifetime determines how long revocation is delayed.

An application session is a third option: each request consults—or uses a cache of—an application session store. It makes central invalidation straightforward but makes that store part of the critical path. There is no universal count of identity calls: it depends on JWT or opaque tokens, cache TTLs, gateways, accepted risk, consistency requirements, and topology.

JWT is a claims format, not OAuth. OAuth can issue JWT or opaque access tokens, and a JWT can be used outside OAuth. A JWKS is a set of public JWKs; OAuth introspection defines the active signal for centrally managed tokens.

Why tokens expire

Short expiry reduces the period in which a stolen credential can be used. It does not prevent theft: an attacker can use a valid token until it expires. A refresh token, when the provider grants one, can obtain a new access token without forcing a fresh sign-in. During that renewal, the server can reassess the account, client, consent, and permissions.

A permission change does not magically invalidate every JWT already issued. Until exp, an API doing only local validation sees the earlier claims. Depending on risk, systems combine short expiry, introspection or revocation lists for sensitive actions, token versioning, sign-out, and API policy re-evaluation.

Refresh tokens deserve more protection than a short-lived access token. Rotation issues a replacement on use and invalidates the old one; reuse of an old token can signal theft. Revocation explicitly removes credential or grant validity. Sign-out clears the client session and may notify the provider; expiry happens at exp; revocation is a server decision before then. They complement one another.

A reproducible local example: Keycloak, JavaScript, and FastAPI

The following is functional code, not pseudocode, once the described configuration is applied. It deliberately uses in-memory data and a small ownership policy to make the three important checks visible: a valid token, a permission, and a resource. It does not build an OAuth provider or implement cryptography.

1. Start and configure Keycloak

Run a pinned local provider image; this is development configuration, not a production recipe:

docker run --rm --name photos-keycloak -p 8080:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:26.0.7 start-dev

At http://localhost:8080/admin/, create realm photos and users alice, bob, and reader. Create public OIDC client photos-spa with Client authentication off, Standard flow on, PKCE S256, redirect URI http://localhost:5173/*, and web origin http://localhost:5173. Create photos-api too, then add an Audience mapper to photos-spa with photos-api as its audience.

For the exercise, create optional client scopes photos:read and photos:write, and request them from the SPA. Assign realm role photo-editor to Alice and Bob, but not Reader. In a real system, scope or role issuance must come from authorization-server policy, not from a browser writing a scope name in a URL. The extra role makes that distinction observable in this demo.

Keycloak maintains keycloak-js: its documentation says browser clients must be public, the adapter defaults to Authorization Code, and it keeps tokens in memory.

2. Public JavaScript client

Install the library and serve the file from http://localhost:5173 with your normal development tool:

npm install keycloak-js
// main.js — functional SPA demo code
import Keycloak from "keycloak-js";

const keycloak = new Keycloak({
  url: "http://localhost:8080",
  realm: "photos",
  clientId: "photos-spa",
});

await keycloak.init({
  onLoad: "login-required",
  scope: "photos:read photos:write",
  pkceMethod: "S256",
  checkLoginIframe: false,
});

async function api(path, options = {}) {
  await keycloak.updateToken(30);
  return fetch(`http://localhost:8000${path}`, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${keycloak.token}`,
      "Content-Type": "application/json",
    },
  });
}

export async function readPhoto(id) {
  return api(`/photos/${id}`);
}

export async function renamePhoto(id, title) {
  return api(`/photos/${id}`, {
    method: "PATCH",
    body: JSON.stringify({ title }),
  });
}

There is no—and must not be—a client_secret in browser code: anyone can read it. In-memory access tokens reduce persistence after a later XSS or profile copy, but do not eliminate XSS or protect an already compromised page. Reloading loses the state and resumes the provider session. Never log tokens to console, analytics, URLs, or error reports.

3. FastAPI validates the access token

Install PyJWT with cryptographic support:

python -m pip install "fastapi[standard]" "pyjwt[crypto]"

Save this as app.py and run fastapi dev app.py. Its audience matches Keycloak’s Audience mapper; allowing only RS256 prevents the untrusted token header from selecting an algorithm.

# app.py — functional demo code; replace PHOTOS with a database
from uuid import uuid4

import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError, PyJWKClientError
from pydantic import BaseModel

ISSUER = "http://localhost:8080/realms/photos"
AUDIENCE = "photos-api"
ALGORITHMS = ["RS256"]
JWKS_URL = f"{ISSUER}/protocol/openid-connect/certs"

jwks_client = PyJWKClient(JWKS_URL, cache_keys=True, lifespan=300)
bearer = HTTPBearer(auto_error=False)
app = FastAPI()
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],
    allow_credentials=False,
    allow_methods=["GET", "POST", "PATCH"],
    allow_headers=["Authorization", "Content-Type"],
)
PHOTOS: dict[str, dict[str, str]] = {}


class PhotoPatch(BaseModel):
    title: str


def unauthorized(detail: str) -> HTTPException:
    return HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail=detail,
        headers={"WWW-Authenticate": "Bearer"},
    )


def current_claims(
    credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
) -> dict:
    if credentials is None or credentials.scheme.lower() != "bearer":
        raise unauthorized("Missing bearer access token")
    try:
        signing_key = jwks_client.get_signing_key_from_jwt(credentials.credentials)
        return jwt.decode(
            credentials.credentials,
            signing_key.key,
            algorithms=ALGORITHMS,
            issuer=ISSUER,
            audience=AUDIENCE,
            options={"require": ["exp", "iss", "sub", "aud"]},
        )
    except ExpiredSignatureError:
        raise unauthorized("Access token expired")
    except (InvalidTokenError, PyJWKClientError):
        raise unauthorized("Invalid access token")


def require_scope(required: str):
    def dependency(claims: dict = Depends(current_claims)) -> dict:
        scopes = set(claims.get("scope", "").split())
        if required not in scopes:
            raise HTTPException(status_code=403, detail=f"Missing scope: {required}")
        return claims
    return dependency


def require_editor(claims: dict) -> None:
    roles = set(claims.get("realm_access", {}).get("roles", []))
    if "photo-editor" not in roles:
        raise HTTPException(status_code=403, detail="Editor role required")


@app.post("/photos", status_code=201)
def create_photo(claims: dict = Depends(require_scope("photos:write"))):
    require_editor(claims)
    photo_id = str(uuid4())
    PHOTOS[photo_id] = {"owner_sub": claims["sub"], "title": "New photo"}
    return {"id": photo_id, "owner_sub": claims["sub"]}


@app.get("/photos/{photo_id}")
def read_photo(photo_id: str, claims: dict = Depends(require_scope("photos:read"))):
    photo = PHOTOS.get(photo_id)
    if photo is None:
        raise HTTPException(status_code=404, detail="Photo not found")
    return {"id": photo_id, "title": photo["title"]}


@app.patch("/photos/{photo_id}")
def update_photo(
    photo_id: str,
    patch: PhotoPatch,
    claims: dict = Depends(require_scope("photos:write")),
):
    require_editor(claims)
    photo = PHOTOS.get(photo_id)
    if photo is None:
        raise HTTPException(status_code=404, detail="Photo not found")
    if photo["owner_sub"] != claims["sub"]:
        raise HTTPException(status_code=403, detail="You do not own this photo")
    photo["title"] = patch.title
    return {"id": photo_id, "title": photo["title"]}

PyJWKClient resolves a key by kid, caches JWKS and keys, and retrieves JWKS again when it cannot find a key; PyJWT documents both behaviours. The API neither trusts an unverified decoded sub nor accepts algorithms outside its configuration. Add HTTPS, limits, secret-free logs, a transactional database, and an explicit private-read policy in production.

4. Manual verification matrix

After signing in as Alice, create a photo with POST /photos in the SPA and retain its id. The above configuration should produce:

CaseRequestExpected result
Allowed readAlice: GET /photos/{id} with photos:read200.
Own writeAlice: PATCH /photos/{id} with photos:write and photo-editor200.
Insufficient scope/roleReader: PATCH /photos/{id}403 before any change.
Someone else’s photoBob: PATCH /photos/{id} with write and editor403: sub does not equal owner_sub.
Wrong audienceAn access token without aud: photos-api401 in jwt.decode.
Expired tokenTemporarily shorten the realm Access Token Lifespan, capture a token, and use it after exp without calling updateToken401 Access token expired.

For the wrong-audience case, create a second identical public client without the Audience mapper, sign in through it, and call the API. The validator must not accept a token minted for a different audience. This is a 401, not a 403: the credential fails the API’s basic contract.

An attractive alternative for web applications is a BFF (Backend for Frontend): a confidential server exchanges the code and stores tokens server-side, while the browser gets only an HttpOnly, Secure, suitably SameSite session cookie. That reduces direct token exposure to JavaScript, but requires CSRF defenses: validate Origin/Referer where applicable, use an anti-forgery token for state-changing operations, and never open CORS or cookies to every origin.

Facebook is a useful reminder that a scope never operates in a vacuum. Its permissions, app mode, test accounts, and app review determine which data a client may receive even after a person consents. The official user_friends documentation limits results to friends who also use the app and granted that permission; it does not permit freely traversing friends of friends. Permissions needing advanced access are subject to policy and App Review.

So “the user accepted” does not prove that an API will return some data: the provider may restrict it, the app may not be approved, and the resource API retains its own policy. It is the same principle as our gallery: consent and a token are inputs to the decision, not substitutes for it.

Sources and further reading