---
title: 'OAuth 2 Flows'
source: 'https://academia.sh/en/courses/authentication-and-authorization/oauth-2-flows'
course: 'Authentication and Authorization'
language: en
updated: '2026-08-19T05:19:32+00:00'
license: 'CC BY-SA 4.0'
---

# OAuth 2 Flows

Building authorization delegation with four roles: the authorization code flow run end to end with an authorization server, a resource server, and a client; the rules of the PKCE verifier and the state parameter; the code's single use; and the client credentials and device flows.

Up to this point, every step of authentication has taken place on a single side: the
user gave their password to the loan service, and the loan service issued the token
and verified it itself too. This arrangement does not work when a member wants to
expose their loan history to a third-party reading app — the application would have to
be given the password. And an application holding the password would not only read the
loan history; it could do anything the member could do, and access could only be cut
off by changing the password.

**OAuth 2** solves this problem by delegating authorization. The resource owner gives
their credential only to their own authorization server; what the application gets is
a token limited in scope and lifetime.

## Four Roles

The **resource owner** is the party that grants authorization — here, member U-1001.
The **client** is the application requesting authorization. The **authorization
server** authenticates the resource owner, obtains their consent, and issues the
token. The **resource server** verifies the token and returns the protected data.

In the authorization code flow, the client does not receive the token directly. The
resource owner is redirected to the authorization server, a short-lived
**authorization code** comes back to the client from there, and the client exchanges
this code for a token with a separate request that does not pass through the browser.
This is why the token never appears in the browser's address bar.

## The Authorization Server

```js
// authorization-server.mjs — authorization code (PKCE), client credentials, and device flows
// Usage: node authorization-server.mjs [port]     (default 8551)
import { createServer } from "node:http";
import { createHmac, createHash, randomBytes, timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8551);
const ISSUER = `http://127.0.0.1:${PORT}`;
const KEY = Buffer.from("identity-provider-signing-key-32bytes!!!", "utf8");

// A "public" client cannot store a secret (browser/mobile); a "confidential" client can.
const CLIENTS = new Map([
  ["reading-app", { type: "public", redirectUris: ["http://127.0.0.1:8553/callback"], scopes: ["loan:read"] }],
  ["shelf-terminal", { type: "confidential", secret: "shelf-terminal-secret-1", scopes: ["loan:read", "loan:write"] }],
]);
const CODES = new Map(), DEVICES = new Map();

const s256 = (v) => createHash("sha256").update(v).digest("base64url");
const isEqual = (a, b) => {
  const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8");
  return x.length === y.length && timingSafeEqual(x, y);
};
const issueAccess = (sub, client, scopes) => {
  const body = Buffer.from(JSON.stringify({ iss: ISSUER, sub, aud: "loan-service", client_id: client,
    scope: scopes.join(" "), exp: Math.floor(Date.now() / 1000) + 600 })).toString("base64url");
  return `${body}.${createHmac("sha256", KEY).update(body).digest("base64url")}`;
};
const readForm = (request) => new Promise((resolve) => {
  let v = ""; request.on("data", (p) => (v += p));
  request.on("end", () => resolve(Object.fromEntries(new URLSearchParams(v))));
});

createServer(async (request, response) => {
  response.sendDate = false;
  const url = new URL(request.url, ISSUER), s = url.searchParams;
  const json = (code, body) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
    response.end(JSON.stringify(body));
  };

  if (request.method === "GET" && url.pathname === "/authorize") {
    const client = CLIENTS.get(s.get("client_id") ?? ""), redirect = s.get("redirect_uri") ?? "";
    // If the client or redirect address is not recognized, NO REDIRECT HAPPENS; the error stops here.
    if (!client || !(client.redirectUris ?? []).includes(redirect)) {
      return json(400, { error: "invalid_request", detail: "client or redirect address not recognized" });
    }
    const returnWithError = (code) => {
      const u = new URL(redirect);
      u.searchParams.set("error", code);
      if (s.get("state")) u.searchParams.set("state", s.get("state"));
      response.writeHead(302, { location: u.toString() }); response.end();
    };
    if (s.get("response_type") !== "code" || !s.get("state")) return returnWithError("invalid_request");
    if (!s.get("code_challenge") || s.get("code_challenge_method") !== "S256") return returnWithError("invalid_request");
    const requested = (s.get("scope") ?? "").split(" ").filter(Boolean);
    if (!requested.length || !requested.every((k) => client.scopes.includes(k))) return returnWithError("invalid_scope");

    // At this point the resource owner is considered to have authenticated and passed the consent screen.
    const code = randomBytes(24).toString("base64url");
    CODES.set(code, { client_id: s.get("client_id"), redirect_uri: redirect, challenge: s.get("code_challenge"),
      scopes: requested, sub: "U-1001", exp: Date.now() + 60_000, used: false });
    const u = new URL(redirect);
    u.searchParams.set("code", code); u.searchParams.set("state", s.get("state"));
    response.writeHead(302, { location: u.toString() });
    return response.end();
  }

  if (request.method === "POST" && url.pathname === "/device/code") {
    const form = await readForm(request);
    const deviceCode = randomBytes(18).toString("base64url"), userCode = randomBytes(3).toString("hex").toUpperCase();
    DEVICES.set(deviceCode, { client_id: form.client_id, userCode, approved: false, scopes: (form.scope ?? "").split(" ") });
    return json(200, { device_code: deviceCode, user_code: userCode,
      verification_uri: `${ISSUER}/device`, expires_in: 600, interval: 1 });
  }
  if (request.method === "POST" && url.pathname === "/device/approve") {
    const form = await readForm(request);
    for (const [, d] of DEVICES) if (d.userCode === form.user_code) d.approved = true;
    return json(200, { approved: true });
  }

  if (request.method === "POST" && url.pathname === "/token") {
    const form = await readForm(request);

    if (form.grant_type === "authorization_code") {
      const record = CODES.get(form.code ?? "");
      if (!record) return json(400, { error: "invalid_grant", detail: "code not recognized" });
      if (record.used) { CODES.delete(form.code); return json(400, { error: "invalid_grant", detail: "code presented a second time" }); }
      if (Date.now() > record.exp) return json(400, { error: "invalid_grant", detail: "code has expired" });
      if (form.client_id !== record.client_id) return json(400, { error: "invalid_grant", detail: "client does not match" });
      if (form.redirect_uri !== record.redirect_uri) return json(400, { error: "invalid_grant", detail: "redirect does not match" });
      if (!form.code_verifier || !isEqual(s256(form.code_verifier), record.challenge)) {
        return json(400, { error: "invalid_grant", detail: "verifier does not match" });
      }
      record.used = true;
      return json(200, { access_token: issueAccess(record.sub, record.client_id, record.scopes),
        token_type: "Bearer", expires_in: 600, scope: record.scopes.join(" ") });
    }

    if (form.grant_type === "client_credentials") {
      const [name, secret] = Buffer.from((request.headers.authorization ?? "").replace("Basic ", ""), "base64")
        .toString("utf8").split(":");
      const client = CLIENTS.get(name ?? "");
      if (!client || client.type !== "confidential" || !isEqual(secret ?? "", client.secret)) return json(401, { error: "invalid_client" });
      const requested = (form.scope ?? "").split(" ").filter(Boolean);
      if (!requested.every((k) => client.scopes.includes(k))) return json(400, { error: "invalid_scope" });
      return json(200, { access_token: issueAccess(name, name, requested), token_type: "Bearer",
        expires_in: 600, scope: requested.join(" ") });
    }

    if (form.grant_type === "urn:ietf:params:oauth:grant-type:device_code") {
      const record = DEVICES.get(form.device_code ?? "");
      if (!record) return json(400, { error: "invalid_grant" });
      if (!record.approved) return json(400, { error: "authorization_pending" });
      DEVICES.delete(form.device_code);
      return json(200, { access_token: issueAccess("U-1001", record.client_id, record.scopes),
        token_type: "Bearer", expires_in: 600, scope: record.scopes.join(" ") });
    }
    return json(400, { error: "unsupported_grant_type" });
  }
  json(404, { error: "not_found" });
}).listen(PORT, "127.0.0.1", () => console.log(`authorization server ${ISSUER}`));
```

The order of the checks at the authorization endpoint is not arbitrary. If the client
or the redirect address is not recognized, **no redirect happens**: the error stops in
the resource owner's browser. In a setup where the redirect address is not verified,
the error response — or worse, the authorization code — could be sent to an address
that is not registered. The redirect address is checked by exact match; prefix
matching or a wildcard is not accepted.

## The Resource Server

The resource server verifies the token and looks for the **scope** required by the
endpoint.

```js
// resource-server.mjs — loan service: verifies the token and checks scope
// Usage: node resource-server.mjs [port]     (default 8552)
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8552);
const KEY = Buffer.from("identity-provider-signing-key-32bytes!!!", "utf8");
const REQUIRED = new Map([["GET", "loan:read"], ["POST", "loan:write"]]);

const verify = (token) => {
  const [body, signature] = (token ?? "").split(".");
  if (!body || !signature) return null;
  const expected = createHmac("sha256", KEY).update(body).digest("base64url");
  const a = Buffer.from(expected, "utf8"), b = Buffer.from(signature, "utf8");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
  const claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
  if (claims.iss !== "http://127.0.0.1:8551" || claims.aud !== "loan-service") return null;
  return Math.floor(Date.now() / 1000) < claims.exp ? claims : null;
};

createServer((request, response) => {
  response.sendDate = false;
  const json = (code, body) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8" });
    response.end(JSON.stringify(body));
  };
  const header = request.headers.authorization ?? "";
  const claims = header.startsWith("Bearer ") ? verify(header.slice(7)) : null;
  if (!claims) return json(401, { error: "invalid_token" });

  const required = request.url.split("?")[0] === "/loans" ? REQUIRED.get(request.method) : undefined;
  if (!required) return json(404, { error: "not_found" });
  if (!claims.scope.split(" ").includes(required)) {
    return json(403, { error: "insufficient_scope", required, given: claims.scope });
  }
  json(request.method === "GET" ? 200 : 201, { sub: claims.sub, client: claims.client_id, scope: claims.scope });
}).listen(PORT, "127.0.0.1", () => console.log(`resource server http://127.0.0.1:${PORT}`));
```

## The Client

The client generates two values and keeps both of them itself: `state` and the PKCE
**verifier**. What is sent to the authorization server is not the verifier itself but
its digest — the **challenge**.

```js
// client.mjs — reading app: starts the flow, handles the callback, gets the token
// Usage: node client.mjs [port]     (default 8553)
import { createServer } from "node:http";
import { createHash, randomBytes } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8553);
const SELF = `http://127.0.0.1:${PORT}`, AS = "http://127.0.0.1:8551", RESOURCE = "http://127.0.0.1:8552";
const PENDING = new Map();       // state -> PKCE verifier; the record lives on the client
const s256 = (v) => createHash("sha256").update(v).digest("base64url");

createServer(async (request, response) => {
  response.sendDate = false;
  const url = new URL(request.url, SELF);
  const json = (code, body) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8" });
    response.end(JSON.stringify(body));
  };

  if (url.pathname === "/start") {
    const state = randomBytes(12).toString("base64url"), verifier = randomBytes(32).toString("base64url");
    PENDING.set(state, verifier);
    const u = new URL(`${AS}/authorize`);
    for (const [name, value] of Object.entries({ response_type: "code", client_id: "reading-app",
      redirect_uri: `${SELF}/callback`, scope: "loan:read", state,
      code_challenge: s256(verifier), code_challenge_method: "S256" })) u.searchParams.set(name, value);
    return json(200, { authorizationUrl: u.toString() });
  }

  if (url.pathname === "/callback") {
    const state = url.searchParams.get("state");
    // 1. state must match the client's own record; if not, this flow does not belong here.
    if (!state || !PENDING.has(state)) return json(400, { error: "state did not match" });
    const verifier = PENDING.get(state);
    PENDING.delete(state);                                  // state is single-use
    if (url.searchParams.get("error")) return json(400, { error: url.searchParams.get("error") });

    // 2. The code is exchanged for a token together with the verifier.
    const tokenResponse = await fetch(`${AS}/token`, {
      method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ grant_type: "authorization_code", code: url.searchParams.get("code"),
        redirect_uri: `${SELF}/callback`, client_id: "reading-app", code_verifier: verifier }),
    });
    const token = await tokenResponse.json();
    if (!tokenResponse.ok) return json(400, { error: token });

    // 3. The resource server is called with the token.
    const resource = await fetch(`${RESOURCE}/loans`, { headers: { authorization: `Bearer ${token.access_token}` } });
    return json(200, { scope: token.scope, resourceStatus: resource.status, resourceBody: await resource.json() });
  }
  json(404, { error: "no such resource" });
}).listen(PORT, "127.0.0.1", () => console.log(`client ${SELF}`));
```

The two values' functions are separate, and both are mandatory.

`state` verifies that the returning request belongs to a flow the client **started
itself**. The client generates the `state` value, keeps it, and on the way back checks
whether it is on record; a callback that does not match is the result of a flow the
client did not start, and is rejected. A client that does not check `state` can bind
the code of an externally started flow to its own user's session. This is why the
setup must be rejected if `state` is not generated or not checked.

The PKCE verifier binds the authorization code to **the client that requested it**.
The authorization server stores the code together with the challenge; on the token
request, the verifier's digest is compared against this challenge. Because the
verifier never leaves the client's memory, a party that gets hold of the code from
somewhere else has no piece in hand to exchange it for a token. For public clients
that cannot store a secret, this is the code's only protection; this is why the
request must be rejected if `code_challenge` is missing or the method is not `S256`.

## Running the Flow End to End

Three servers are brought up, and `curl` plays the browser's role: it gets the
authorization address from the client, goes to that address, and follows the returned
`Location` header.

```bash
node authorization-server.mjs > /dev/null 2>&1 & Y=$!
node resource-server.mjs > /dev/null 2>&1 & K=$!
node client.mjs > /dev/null 2>&1 & I=$!
sleep 1.2
ADDR=$(curl -s http://127.0.0.1:8553/start | sed -n 's/.*"authorizationUrl":"\([^"]*\)".*/\1/p')
echo "1) authorization address:"; echo "   $ADDR"
BACK=$(curl -s -o /dev/null -w '%{redirect_url}' "$ADDR")
echo "2) callback address:"; echo "   $BACK"
echo "3) client's result:"; curl -s "$BACK"; echo
kill $Y $K $I
```

```
1) authorization address:
   http://127.0.0.1:8551/authorize?response_type=code&client_id=reading-app&redirect_uri=http%3A%2F%2F127.0.0.1%3A8553%2Fcallback&scope=loan%3Aread&state=rGkCVLl6FMBezD1T&code_challenge=JtgCbusArRl8pHEGWyMBpV6P_TaFfjCEzQsAoKiAyM8&code_challenge_method=S256
2) callback address:
   http://127.0.0.1:8553/callback?code=7ICTKjl1Ro-fpP8DjsD0c9yzOUu-ADWy&state=rGkCVLl6FMBezD1T
3) client's result:
{"scope":"loan:read","resourceStatus":200,"resourceBody":{"sub":"U-1001","client":"reading-app","scope":"loan:read"}}
```

`state`, the challenge, and the code change on every run. The second line shows that
the only thing passing through the browser is the code; the token appears at no
address. In the resource server's response, `sub` states the member and `client`
states the application separately: the resource server can distinguish on whose
behalf a request comes from which application it comes through.

## Checks at the Token Endpoint

The block below tries the code directly at the token endpoint; the verifier and the
challenge are generated in the shell.

```bash
node authorization-server.mjs > /dev/null 2>&1 & Y=$!
sleep 1
AS=http://127.0.0.1:8551
CALLBACK=http://127.0.0.1:8553/callback
VERIFIER=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=')
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
getCode() {
  curl -s -o /dev/null -w '%{redirect_url}' \
    "$AS/authorize?response_type=code&client_id=reading-app&redirect_uri=$CALLBACK&scope=loan:read&state=abc&code_challenge=$CHALLENGE&code_challenge_method=S256" \
    | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p'
}
exchange() { curl -s -d "$1" "$AS/token" | sed 's/"access_token":"[^"]*"/"access_token":"…"/'; }
CODE=$(getCode)
echo "valid exchange       : $(exchange "grant_type=authorization_code&code=$CODE&redirect_uri=$CALLBACK&client_id=reading-app&code_verifier=$VERIFIER")"
echo "same code twice      : $(exchange "grant_type=authorization_code&code=$CODE&redirect_uri=$CALLBACK&client_id=reading-app&code_verifier=$VERIFIER")"
CODE=$(getCode)
echo "verifier missing     : $(exchange "grant_type=authorization_code&code=$CODE&redirect_uri=$CALLBACK&client_id=reading-app")"
CODE=$(getCode)
echo "another redirect     : $(exchange "grant_type=authorization_code&code=$CODE&redirect_uri=http://127.0.0.1:8553/other&client_id=reading-app&code_verifier=$VERIFIER")"
kill $Y
```

```
valid exchange       : {"access_token":"…","token_type":"Bearer","expires_in":600,"scope":"loan:read"}
same code twice      : {"error":"invalid_grant","detail":"code presented a second time"}
verifier missing     : {"error":"invalid_grant","detail":"verifier does not match"}
another redirect     : {"error":"invalid_grant","detail":"redirect does not match"}
```

The code is single-use, and on its second presentation the record is dropped entirely
— the same reuse detection built for refresh tokens. A missing verifier and a wrong
verifier being presented get the same response; making a distinction would serve no
purpose other than leaking information to the outside.

## Two More Flows

The **client credentials** flow is for the case where there is no resource owner: the
shelf terminal acts on its own behalf, proves its identity with its own secret, and
its scope comes from its own registration. The **device flow** is for devices that
cannot open a browser: the device gets a user code, the user approves that code on
another device, and the device polls in the meantime.

```bash
node authorization-server.mjs > /dev/null 2>&1 & Y=$!
node resource-server.mjs > /dev/null 2>&1 & K=$!
sleep 1
AS=http://127.0.0.1:8551
shorten() { sed 's/"access_token":"[^"]*"/"access_token":"…"/'; }
RESP=$(curl -s -u shelf-terminal:shelf-terminal-secret-1 -d 'grant_type=client_credentials&scope=loan:read loan:write' $AS/token)
echo "$RESP" | shorten
TOKEN=$(echo "$RESP" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "POST /loans: $(curl -s -X POST -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8552/loans)"
READONLY=$(curl -s -u shelf-terminal:shelf-terminal-secret-1 -d 'grant_type=client_credentials&scope=loan:read' $AS/token | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "POST /loans with read-only scope: $(curl -s -X POST -H "Authorization: Bearer $READONLY" http://127.0.0.1:8552/loans)"
DEVICE=$(curl -s -d 'client_id=shelf-terminal&scope=loan:read' $AS/device/code)
echo "$DEVICE"
DEVICE_CODE=$(echo "$DEVICE" | sed -n 's/.*"device_code":"\([^"]*\)".*/\1/p')
USER_CODE=$(echo "$DEVICE" | sed -n 's/.*"user_code":"\([^"]*\)".*/\1/p')
echo "poll before approval: $(curl -s -d "grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=$DEVICE_CODE" $AS/token)"
curl -s -o /dev/null -d "user_code=$USER_CODE" $AS/device/approve
echo "poll after approval : $(curl -s -d "grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=$DEVICE_CODE" $AS/token | shorten)"
kill $Y $K
```

```
{"access_token":"…","token_type":"Bearer","expires_in":600,"scope":"loan:read loan:write"}
POST /loans: {"sub":"shelf-terminal","client":"shelf-terminal","scope":"loan:read loan:write"}
POST /loans with read-only scope: {"error":"insufficient_scope","required":"loan:write","given":"loan:read"}
{"device_code":"6kPpAgjAdv9XmiYLCEoGtVUW","user_code":"1DA15F","verification_uri":"http://127.0.0.1:8551/device","expires_in":600,"interval":1}
poll before approval: {"error":"authorization_pending"}
poll after approval : {"access_token":"…","token_type":"Bearer","expires_in":600,"scope":"loan:read"}
```

The device and user codes change on every run. The third line shows what scope does:
the same client, with a token it obtained with only the read scope, gets a 403 at the
write endpoint. Scope narrows not who the token belongs to, but **what it is allowed
to do**. In the device flow, polling gets `authorization_pending` until approval
arrives; the device must not poll more often than the `interval` value stated in the
response.

## Summary

- Authorization delegation defines four roles: resource owner, client, authorization
  server, and resource server. The resource owner gives their credential only to the
  authorization server.
- In the authorization code flow, only the short-lived code passes through the
  browser; the token is obtained with a separate request that does not pass through
  the browser.
- The redirect address is checked by exact match, and nothing is sent to an
  unrecognized address; the setup must be rejected if `state` is not generated or
  checked, if `code_challenge` is missing, or if the method is not `S256`.
- The authorization code is single-use; on its second presentation the record is
  dropped, and a mismatched or missing verifier gets the same response.
- The client credentials flow is for machine calls with no resource owner; the device
  flow moves approval to another device for devices that cannot open a browser.
- Scope narrows what the token is allowed to do; the resource server looks for the
  scope required by the endpoint and returns a 403 when it is missing.

## Next Step

None of these flows answered the question "who is this user?" The resource server
read the `sub` field in the token, but what that field means, which user it
corresponds to, and whether the token was really issued as the result of
authentication all fall outside the scope of the authorization delegation protocol. If
the client needs to recognize the user — to display their name, match their account,
open their session — a separate layer is required. The next lesson builds this layer:
the authorization server gives the client an **identity token** alongside the access
token, and the client verifies it with issuer, audience, duration, and signature
steps.
