---
title: 'OpenID Connect'
source: 'https://academia.sh/en/courses/authentication-and-authorization/openid-connect'
course: 'Authentication and Authorization'
language: en
updated: '2026-08-19T05:19:32+00:00'
license: 'CC BY-SA 4.0'
---

# OpenID Connect

Adding an identity layer on top of the authorization delegation protocol: the ID token issued with the openid scope, its separation from the access token by audience, asymmetric signature verification through the discovery document and key set, and the nonce, issuer, audience, and duration steps.

None of the previous lesson's flows answered the question "who is this user?" The
resource server read the `sub` field in the token, but which user that field
corresponds to, and whether the token was really issued as the result of
authentication, fall outside the scope of the authorization delegation protocol. The
access token says "this request can access these resources"; it does not say "this
person just authenticated their identity."

**OpenID Connect** fills this gap by adding an identity layer on top of the
authorization delegation protocol. When the client requests the `openid` scope, a
second token is added to the token response: the **ID token**. This token belongs not
to the resource server but to the **client**, and it states who the user is and when
they were authenticated.

## Two Tokens, Two Recipients

The distinction shows up in the `aud` claim. The access token's audience is the
resource server; the ID token's audience is the client itself. Two rules follow from
this: the client does not send the ID token to the API, and the resource server does
not accept the ID token. When the two are confused, a token issued for one service
ends up being presented as proof to another.

The ID token is also issued **to be verified**, not to be carried around. The client
verifies it once, writes the information inside it into its own session, and has no
need to keep the token.

## The Provider

The provider below extends the previous lesson's authorization code flow with the
identity layer: the `openid` scope and the `nonce` parameter are mandatory, the
signature is asymmetric, and public keys are published from a **key set** (JSON Web
Key Set) endpoint.

```js
// provider.mjs — identity layer: discovery document, key set, ID token, userinfo endpoint
// Usage: node provider.mjs [port]     (default 8561)
import { createServer } from "node:http";
import { generateKeyPairSync, createHash, randomBytes, sign, verify, timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8561);
const ISSUER = `http://127.0.0.1:${PORT}`;
const CLIENT = { id: "reading-app", redirectUri: "http://127.0.0.1:8563/callback" };
const USER = { sub: "U-1001", name: "Clara Diaz", email: "clara.diaz@example.library", branch: "central" };

// Two keys: the current one and the retired one. Both stay in the key set.
const makeKey = (kid) => ({ kid, ...generateKeyPairSync("rsa", { modulusLength: 2048 }) });
const KEYS = [makeKey("2026-a"), makeKey("2025-b")];
const CURRENT = KEYS[0];

const CODES = new Map();
const b64 = (v) => Buffer.from(v).toString("base64url");
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 issueJws = (key, claims) => {
  const data = `${b64(JSON.stringify({ alg: "RS256", typ: "JWT", kid: key.kid }))}.${b64(JSON.stringify(claims))}`;
  return `${data}.${sign("sha256", Buffer.from(data), key.privateKey).toString("base64url")}`;
};

const verifyJws = (token) => {
  const [h, b, s] = (token ?? "").split(".");
  if (!h || !b || !s) return null;
  const header = JSON.parse(Buffer.from(h, "base64url").toString("utf8"));
  const key = KEYS.find((k) => k.kid === header.kid);
  if (!key || header.alg !== "RS256") return null;
  if (!verify("sha256", Buffer.from(`${h}.${b}`), key.publicKey, Buffer.from(s, "base64url"))) return null;
  const claims = JSON.parse(Buffer.from(b, "base64url").toString("utf8"));
  return Math.floor(Date.now() / 1000) < claims.exp ? claims : null;
};

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 (url.pathname === "/.well-known/openid-configuration") {
    return json(200, {
      issuer: ISSUER, authorization_endpoint: `${ISSUER}/authorize`,
      token_endpoint: `${ISSUER}/token`, userinfo_endpoint: `${ISSUER}/userinfo`,
      jwks_uri: `${ISSUER}/keys`, id_token_signing_alg_values_supported: ["RS256"],
      scopes_supported: ["openid", "profile", "loan:read"], response_types_supported: ["code"],
    });
  }

  if (url.pathname === "/keys") {
    return json(200, {
      keys: KEYS.map((k) => ({
        ...k.publicKey.export({ format: "jwk" }), kid: k.kid, alg: "RS256", use: "sig",
      })),
    });
  }

  if (request.method === "GET" && url.pathname === "/authorize") {
    if (s.get("client_id") !== CLIENT.id || s.get("redirect_uri") !== CLIENT.redirectUri) {
      return json(400, { error: "invalid_request" });
    }
    const scopes = (s.get("scope") ?? "").split(" ");
    // The identity layer only kicks in when the "openid" scope is requested.
    if (!scopes.includes("openid")) return json(400, { error: "invalid_scope", detail: "no openid scope" });
    if (!s.get("state") || !s.get("nonce") || !s.get("code_challenge")) return json(400, { error: "invalid_request" });

    const code = randomBytes(24).toString("base64url");
    CODES.set(code, { challenge: s.get("code_challenge"), nonce: s.get("nonce"), scopes, used: false });
    const u = new URL(CLIENT.redirectUri);
    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 === "/token") {
    const form = await readForm(request);
    const record = CODES.get(form.code ?? "");
    if (!record || record.used) return json(400, { error: "invalid_grant" });
    if (!form.code_verifier || !isEqual(s256(form.code_verifier), record.challenge)) return json(400, { error: "invalid_grant" });
    record.used = true;

    const now = Math.floor(Date.now() / 1000);
    // The access token is issued for the resource server; the ID token for the client.
    const access = issueJws(CURRENT, { iss: ISSUER, sub: USER.sub, aud: "loan-service",
      scope: record.scopes.join(" "), exp: now + 600, iat: now });
    const idToken = issueJws(CURRENT, { iss: ISSUER, sub: USER.sub, aud: CLIENT.id,
      nonce: record.nonce, auth_time: now, exp: now + 600, iat: now,
      name: USER.name, email: USER.email });
    return json(200, { access_token: access, id_token: idToken, token_type: "Bearer", expires_in: 600 });
  }

  if (url.pathname === "/userinfo") {
    // The userinfo endpoint requires an access token; the ID token is not valid here.
    const header = request.headers.authorization ?? "";
    if (!header.startsWith("Bearer ")) return json(401, { error: "invalid_token" });
    const claims = verifyJws(header.slice(7));
    if (!claims) return json(401, { error: "invalid_token" });
    if (claims.aud !== "loan-service") return json(401, { error: "invalid_token", detail: "audience is not this endpoint" });
    return json(200, USER);
  }
  json(404, { error: "not_found" });
}).listen(PORT, "127.0.0.1", () => console.log(`provider ${ISSUER}`));
```

The key set publishes two keys: the current one and the retired one. Key rotation can
therefore happen without interruption — a new key is added to the set, signing moves
to it, the old key stays in the set until the tokens signed with it run out their
lifetime, and only then is it removed. The header's `kid` field tells the verifier
which key to select.

```bash
node provider.mjs 8561 > /dev/null 2>&1 & S=$!
sleep 1
curl -s http://127.0.0.1:8561/keys | sed 's/"n":"[^"]\{20\}[^"]*"/"n":"…"/g'
echo
kill $S
```

```
{"keys":[{"kty":"RSA","n":"…","e":"AQAB","kid":"2026-a","alg":"RS256","use":"sig"},{"kty":"RSA","n":"…","e":"AQAB","kid":"2025-b","alg":"RS256","use":"sig"}]}
```

The set holds only public keys (`n` is truncated). The verifying party never sees the
private key; the power to issue tokens stays with the provider. This is how the
scaling problem that shared-secret signing created in the JSON Web Tokens lesson gets
solved.

## The Client and the Verification Steps

The client hardcodes no address; it reads them from the discovery document. After
receiving the token, verification consists of questions answered in sequence.

```js
// client.mjs — reads the discovery document, runs the flow, verifies the ID token step by step
// Usage: while node provider.mjs 8561 &  is running,  node client.mjs
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";

const PROVIDER = "http://127.0.0.1:8561";
const CLIENT_ID = "reading-app";
const REDIRECT_URI = "http://127.0.0.1:8563/callback";
const s256 = (v) => createHash("sha256").update(v).digest("base64url");
const decode = (p) => JSON.parse(Buffer.from(p, "base64url").toString("utf8"));

// 1) Discovery document: endpoint addresses and supported algorithms are read from here.
const discovery = await (await fetch(`${PROVIDER}/.well-known/openid-configuration`)).json();
const keySet = await (await fetch(discovery.jwks_uri)).json();
console.log("issuer            :", discovery.issuer);
console.log("signing algorithms:", discovery.id_token_signing_alg_values_supported.join(", "));
console.log("key set           :", keySet.keys.map((k) => `${k.kid} (${k.kty})`).join(", "));

// 2) Flow: the client generates and keeps state, nonce, and the PKCE verifier itself.
const state = randomBytes(9).toString("base64url");
const nonce = randomBytes(9).toString("base64url");
const verifier = randomBytes(32).toString("base64url");
const u = new URL(discovery.authorization_endpoint);
for (const [name, value] of Object.entries({ response_type: "code", client_id: CLIENT_ID,
  redirect_uri: REDIRECT_URI, scope: "openid profile loan:read", state, nonce,
  code_challenge: s256(verifier), code_challenge_method: "S256" })) u.searchParams.set(name, value);

const authResponse = await fetch(u, { redirect: "manual" });
const callback = new URL(authResponse.headers.get("location"));
if (callback.searchParams.get("state") !== state) throw new Error("state did not match");

const tokens = await (await fetch(discovery.token_endpoint, {
  method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({ grant_type: "authorization_code", code: callback.searchParams.get("code"),
    redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier }),
})).json();

// 3) Verifying the ID token. Each step corresponds to a separate question.
const verifyIdToken = (token, { expectedNonce } = {}) => {
  const [h, b, s] = (token ?? "").split(".");
  if (!h || !b || !s) return "format";
  const header = decode(h);
  if (!discovery.id_token_signing_alg_values_supported.includes(header.alg)) return "algorithm";
  const jwk = keySet.keys.find((k) => k.kid === header.kid && k.alg === header.alg);
  if (!jwk) return "key not recognized";
  const publicKey = createPublicKey({ key: jwk, format: "jwk" });
  if (!verify("sha256", Buffer.from(`${h}.${b}`), publicKey, Buffer.from(s, "base64url"))) return "signature";

  const claims = decode(b), now = Math.floor(Date.now() / 1000);
  if (claims.iss !== discovery.issuer) return "issuer";
  const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
  if (!aud.includes(CLIENT_ID)) return `audience (${claims.aud})`;
  if (now >= claims.exp) return "expired";
  if (expectedNonce !== undefined && claims.nonce !== expectedNonce) return "nonce";
  return { sub: claims.sub, name: claims.name, authenticationTime: claims.auth_time };
};

const print = (name, result) => console.log(name.padEnd(30), typeof result === "string" ? `reject (${result})` : `accept → ${JSON.stringify(result)}`);
print("ID token", verifyIdToken(tokens.id_token, { expectedNonce: nonce }));
print("different expected nonce", verifyIdToken(tokens.id_token, { expectedNonce: "another-value" }));
print("access token when presented", verifyIdToken(tokens.access_token, { expectedNonce: nonce }));

const [h, b, s] = tokens.id_token.split(".");
const bodyAltered = Buffer.from(JSON.stringify({ ...decode(b), sub: "Y-0002" })).toString("base64url");
print("body altered", verifyIdToken(`${h}.${bodyAltered}.${s}`, { expectedNonce: nonce }));
const otherKid = Buffer.from(JSON.stringify({ ...decode(h), kid: "1999-z" })).toString("base64url");
print("unrecognized key id", verifyIdToken(`${otherKid}.${b}.${s}`, { expectedNonce: nonce }));

// 4) The userinfo endpoint requires an access token.
const askUserinfo = async (token) => {
  const response = await fetch(discovery.userinfo_endpoint, { headers: { authorization: `Bearer ${token}` } });
  return `${response.status} ${JSON.stringify(await response.json())}`;
};
console.log("userinfo endpoint / access token:", await askUserinfo(tokens.access_token));
console.log("userinfo endpoint / ID token    :", await askUserinfo(tokens.id_token));
```

```bash
node provider.mjs 8561 > /dev/null 2>&1 & S=$!
sleep 1.2
node client.mjs
kill $S
```

```
issuer            : http://127.0.0.1:8561
signing algorithms: RS256
key set           : 2026-a (RSA), 2025-b (RSA)
ID token                       accept → {"sub":"U-1001","name":"Clara Diaz","authenticationTime":1787079690}
different expected nonce       reject (nonce)
access token when presented    reject (audience (loan-service))
body altered                   reject (signature)
unrecognized key id            reject (key not recognized)
userinfo endpoint / access token: 200 {"sub":"U-1001","name":"Clara Diaz","email":"clara.diaz@example.library","branch":"central"}
userinfo endpoint / ID token    : 401 {"error":"invalid_token","detail":"audience is not this endpoint"}
```

The timestamp changes on every run. The five lines confirm five separate steps.

**Algorithm** is bounded by the list the discovery document states, and is not taken
from the token's header. The rule established in the JSON Web Tokens lesson applies
here too: the algorithms to accept come from the verifier's configuration.

**Key selection** happens between `kid` and the keys in the set. A `kid` with no
counterpart in the set stops verification before it starts. It is normal for the
verifier to refetch the set for a `kid` it does not recognize; but this refetching
must be rate-limited, otherwise every unrecognized value triggers an outbound call.

**Audience** is the step that separates the ID token from the access token. In the
third line, the access token is presented as though it were an ID token and is
rejected because its `aud` field is `loan-service`. The last line shows the same
distinction in reverse: the ID token gets a 401 when presented to the userinfo
endpoint. Even though both tokens come from the same issuer and are signed with the
same key, neither substitutes for the other; the only field that makes the distinction
is the `aud` claim.

**Nonce** binds the ID token to **this** session of the client. The client generates
the value, sends it along with the flow, and looks for the same one in the returned
token. A token that does not match is not recognized if it belongs to another flow.
`state` verifies that the request belongs to the client; `nonce` verifies that the
token belongs to this request; the two are separate questions, and both are required.

The **userinfo endpoint** returns additional information not present in the ID token,
using the access token. Branch information comes back at this endpoint but is not in
the ID token; leaving the information to a separate call instead of growing the token
keeps the header carried on every request small.

## The ID Token's Limit

The ID token is a statement about the past: at the moment `auth_time`, this user's
identity was authenticated. It is not an ongoing authorization. After verifying the
token, the client opens its own session and manages that session by its own rules —
every decision from the Session-Based Authentication lesson applies here too,
including renewing the session identifier at login.

The rule that follows is this: the ID token is not attached to every request, is not
sent to the resource server, and is not kept for long. It is verified once, its
counterpart is written to the local session, and its job is done.

## Summary

- The authorization delegation protocol delegates access authorization; the identity
  layer adds the information of who the user is on top of that, and kicks in with the
  `openid` scope.
- The ID token's audience is the client, the access token's audience is the resource
  server; even signed with the same key, neither substitutes for the other.
- The discovery document states the endpoint addresses and the accepted signing
  algorithms; the key set publishes public keys with `kid` and makes key rotation
  uninterrupted.
- Verification passes in order through the algorithm, key selection, signature,
  issuer, audience, duration, and nonce steps; each step answers a separate question.
- `state` verifies that the request belongs to the client, `nonce` verifies that the
  token belongs to that request; neither can substitute for the other.
- The ID token is a statement of a past authentication; it is verified once, its
  counterpart is written to the local session, and it is not attached to every
  request.

## Next Step

In this setup, the identity provider and the loan service were part of the same
organization: the provider knew the member registry, and the `sub` field corresponded
directly to the member code. This assumption breaks down once district libraries
connect to a shared loan system — each organization has its own user directory, its
own authentication rules, and its own user identities. The next lesson builds this
arrangement: carrying an identity one organization authenticated to another
organization with a signed **assertion**, checking that assertion by its validity
window and audience, preventing the same assertion from being used a second time, and
translating externally supplied attributes into a local role.
