How to buildOctober 11, 202612 min read
APIs & IntegrationsStep 4 of 10

OAuth 2.0 and OpenID Connect: signing in with another account

In short

OAuth 2.0 is a delegation protocol: the user lets an application act on their behalf without handing over a password, and the application receives an access token with a limited scope. OpenID Connect adds sign-in on top: the ID token states who signed in, and it must be validated — iss, aud, exp, nonce and the signature. The default flow today is Authorization Code with PKCE; per RFC 9700, the implicit and password grants are not used.

Deep diveLevel: IntermediateOAuth 2.0 & OpenID Connect

After this you will be able to

  • explain the OAuth 2.0 roles and the steps of the Authorization Code flow with PKCE, including what state, nonce and code_verifier are for
  • tell access, refresh and ID tokens apart by audience, lifetime and how each is validated
  • run a local provider on oidc-provider and complete a full sign-in with an openid-client app, up to the ID token and UserInfo
  • validate an ID token by hand with jose — signature, iss, aud, exp, iat, nonce — and prove with a test that forgeries are rejected

Verified with

Node.js 24 LTS (24.18)openid-client 6.8oidc-provider 9.12jose 6.2

Checked against docs: October 11, 2026

Contents
  1. 01Why OAuth exists and what it is not
  2. 02Authorization Code + PKCE step by step
  3. 03Tokens and scope: access, refresh, ID token
  4. 04OpenID Connect: discovery, ID token and UserInfo
  5. 05The stand: a local provider and an openid-client app
  6. 06Validating the ID token
  7. 07Security mistakes: redirect_uri, state, implicit flow

Why OAuth exists and what it is not

A classic case: a reporting service wants to read a user's calendar in another system. The old answer — ask the user for their password to that system — is bad on every count: the service gets full access, the password lives in two places, and the only way to revoke access is to change the password. OAuth 2.0 (RFC 6749) solves this with delegation: the user approves access on the data owner's side, and the application receives an access token with limited rights and a limited lifetime.

It matters what OAuth is not. It is a protocol for authorizing access to APIs, not a sign-in protocol. An access token is meant for the resource server; the application does not have to — and often cannot — tell from it who the user is. To «sign in with another account» you need OpenID Connect, a layer on top of OAuth that adds the ID token: a signed statement from the provider about which user authenticated, when, and for which application. Sessions, cookies and the JWT format itself are covered in the authentication lesson; this one is about the protocol around them.

Resource owner

Who it is
Whoever can grant access, usually the user
Example
Aigerim, who has an account with the provider

Client

Who it is
The application asking for access on the user's behalf
Example
Your website or mobile app

Authorization server

Who it is
Authenticates the user and issues tokens
Example
Google, a corporate Keycloak, oidc-provider in the example

Resource server

Who it is
The API that accepts the access token
Example
A calendar API, the provider's UserInfo endpoint
The four roles from RFC 6749, section 1.1

In OpenID Connect the authorization server is called the OpenID Provider (OP) and the client is the Relying Party (RP). One more distinction drives the choice of flow: a confidential client (a backend) can keep a secret, a public client (an SPA or a mobile app) cannot, because its code is in the user's hands.

Authorization Code + PKCE step by step

Authorization Code is the default flow for anything with a user and a browser. Tokens never travel through the address bar: the browser only carries a short-lived single-use code, and the code is exchanged for tokens directly between the client's server and the provider. PKCE (Proof Key for Code Exchange, RFC 7636) binds that code to whoever started the sign-in: an intercepted code is useless without the secret «verifier».

  1. The client prepares one-time values

    code_verifier is a random 43–128 character string, code_challenge = BASE64URL(SHA-256(verifier)), plus state and nonce. All of them are stored in the user's server-side session.

  2. Redirect to the authorization endpoint

    The URL carries client_id, redirect_uri, scope, response_type=code, code_challenge, code_challenge_method=S256, state, nonce. The verifier itself is not sent.

  3. The user signs in at the provider

    Password, second factor and consent are entered on the provider's side. The client never sees the password.

  4. Back to redirect_uri with a code

    The provider redirects the browser to the registered address with code, state and iss (RFC 9207). The client checks state and iss.

  5. Code exchange

    The client's server sends code, redirect_uri and code_verifier to the token endpoint and authenticates itself. The provider hashes the verifier and compares it with the challenge from step 2.

  6. Response validation

    The client receives an access token and an ID token (and a refresh token if requested), validates the ID token and creates its own session.

  1. 01Browser
  2. 02Your server (RP)
  3. 03Provider (OP)
  4. 04API / UserInfo
The code comes back through the browser; tokens only travel between the client's server and the provider

Why even a confidential server-side client needs PKCE. RFC 9700 — the current IETF security guidance for OAuth (BCP 240, January 2025) — makes PKCE mandatory for public clients and recommended for confidential ones: it protects against injecting a stolen code into someone else's session (authorization code injection) and closes CSRF on the callback as a side effect. Use S256 only: with plain the challenge equals the verifier and is visible to anyone who can read the authorization request. Here is all the PKCE math on the built-in node:crypto, checked against the test vector from RFC 7636, Appendix B:

pkce.mjs
// PKCE S256 by hand (RFC 7636, section 4): verifier -> SHA-256 -> base64url.
import { createHash, randomBytes } from "node:crypto";

export function createVerifier() {
  return randomBytes(32).toString("base64url"); // 43 characters, allowed range is 43–128
}

export function challengeS256(verifier) {
  return createHash("sha256").update(verifier, "ascii").digest("base64url");
}

// Test vector from RFC 7636, Appendix B
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
console.log(challengeS256(verifier));
console.log(challengeS256(verifier) === "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
console.log(createVerifier().length);
PKCE S256 by hand: in production openid-client does the same (randomPKCECodeVerifier, calculatePKCECodeChallenge)
terminal
$ node pkce.mjs
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
true
43
Real output: the hash matches the RFC 7636 vector, the random verifier is 43 characters long

Tokens and scope: access, refresh, ID token

A token endpoint response can contain three different tokens, and mixing them up is a common source of vulnerabilities. The key question for any token is who it is addressed to.

Access token

Audience
The resource server (API)
What it carries
Rights: scope, expiry, sometimes the resource aud. The format is not standardized: an opaque string or a JWT (RFC 9068)
Who validates it and how
The API — via introspection or JWT signature check. The client does not parse it

Refresh token

Audience
The authorization server
What it carries
The right to get a new access token without the user
Who validates it and how
Only the authorization server; the client stores it as a secret

ID token

Audience
The client (RP)
What it carries
JWT: iss, sub, aud, exp, iat, nonce, sometimes auth_time
Who validates it and how
The client: signature, issuer, audience, expiry, nonce

Scope is a space-separated list of requested rights: openid profile email, reports:read. RFC 9700 advises restricting access tokens to the minimum privileges needed and, where possible, to a specific resource server (the resource parameter from RFC 8707 or aud). Then a token leaked from one integration does not open the other APIs.

A refresh token lives longer and is therefore more dangerous. For public clients RFC 9700 requires either sender-constraining (mTLS, DPoP) or rotation: every refresh issues a new refresh token and invalidates the old one; if the old one is presented again, the server revokes the whole chain. In OpenID Connect a refresh token is usually issued for the offline_access scope, and the provider must obtain explicit consent for it (prompt=consent) — which is why the example below, which does not request that scope, prints has_refresh_token: false.

When there is no user at all — a nightly CRM sync, a background report worker — use the client credentials grant (RFC 6749, section 4.4): the client authenticates with its own secret and gets a token for its own rights. No ID token is issued or needed: nobody is signing in. Each such integration gets its own client with its own secret and a narrow scope.

client-credentials.mjs
// Server-to-server: a background worker gets its own token, no user involved.
import * as client from "openid-client";

const config = await client.discovery(
  new URL(process.env.OIDC_ISSUER),
  process.env.WORKER_CLIENT_ID,
  process.env.WORKER_CLIENT_SECRET,
  undefined,
  { execute: [client.allowInsecureRequests] }, // test IdP on http://localhost only
);

const tokens = await client.clientCredentialsGrant(config, { scope: "reports:read" });
console.log({ token_type: tokens.token_type, expires_in: tokens.expiresIn(), scope: tokens.scope, id_token: tokens.id_token });
Server to server: a separate reports-worker client with a single reports:read scope
terminal
$ node --env-file=.env client-credentials.mjs
{
  token_type: 'bearer',
  expires_in: 600,
  scope: 'reports:read',
  id_token: undefined
}
Real output from the test provider: an access token and no ID token. A reports:write request or a wrong secret is refused — see the tests below

OpenID Connect: discovery, ID token and UserInfo

An OpenID Connect provider publishes its settings at /.well-known/openid-configuration (OpenID Connect Discovery 1.0): endpoint URLs, the JWKS with public signing keys, supported flows and algorithms. The client only needs the issuer — it reads everything else from there. RFC 9700 recommends configuring clients this way: fewer misconfigurations, easier key rotation.

terminal
$ curl -s http://localhost:4100/.well-known/openid-configuration
{
  "issuer": "http://localhost:4100",
  "authorization_endpoint": "http://localhost:4100/auth",
  "token_endpoint": "http://localhost:4100/token",
  "userinfo_endpoint": "http://localhost:4100/me",
  "jwks_uri": "http://localhost:4100/jwks",
  "code_challenge_methods_supported": ["S256"],
  "grant_types_supported": ["implicit", "authorization_code", "refresh_token", "client_credentials"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "authorization_response_iss_parameter_supported": true,
  …
}
Real response from the test provider, shortened at «…». code_challenge_methods_supported shows PKCE support, authorization_response_iss_parameter_supported the iss response parameter

A request becomes an OpenID Connect request when the scope contains openid. Then the token endpoint response includes an ID token. Per the specification, profile data for the profile, email, address and phone scopes is returned by the UserInfo endpoint in the Authorization Code flow, using the access token — which is why the name and email in the output below come from UserInfo, not from the ID token.

  • The only stable user identifier is the iss + sub pair. OpenID Connect Core (section 5.7) explicitly forbids using email, phone_number, preferred_username or name as a unique key: a provider may reassign an email address to another person.
  • The sub in the UserInfo response must match the sub in the ID token, otherwise the UserInfo data must not be used (section 5.3.2). In openid-client that is the third argument of fetchUserInfo.
  • email without email_verified: true does not prove ownership of the address; linking an existing account by it is dangerous.

The stand: a local provider and an openid-client app

To see the whole flow without registering an application with an external provider, we run a local OpenID Provider on oidc-provider — a certified server implementation for Node.js — and a client on openid-client by the same author. The provider is for development only: data in memory, a built-in login form that accepts any password, a signing key generated on every start. The secrets in the example are test strings.

bash
mkdir oidc-stand && cd oidc-stand
npm init -y
npm install openid-client@6.8.8 oidc-provider@9.12.2 jose@6.2.12
A throwaway project; versions from the npm registry on the verification date
provider.mjs
// Test identity provider for local development only: in-memory storage,
// built-in dev login form, keys generated on every start.
import { generateKeyPairSync } from "node:crypto";
import Provider from "oidc-provider";

const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });

const users = {
  "aigerim": { sub: "aigerim", name: "Aigerim Test", email: "aigerim@example.test", email_verified: true },
};

const provider = new Provider("http://localhost:4100", {
  clients: [
    {
      client_id: "interlab-web",
      client_secret: "test-secret-change-me",
      redirect_uris: ["http://localhost:4200/callback"],
      grant_types: ["authorization_code", "refresh_token"],
    },
    {
      client_id: "reports-worker",
      client_secret: "test-worker-secret",
      grant_types: ["client_credentials"],
      redirect_uris: [],
      response_types: [],
      scope: "reports:read",
    },
  ],
  jwks: { keys: [{ ...privateKey.export({ format: "jwk" }), kid: "test-key-1", use: "sig", alg: "RS256" }] },
  cookies: { keys: ["test-cookie-key-change-me"] },
  pkce: { required: () => true },
  scopes: ["openid", "profile", "email", "offline_access", "reports:read", "reports:write"],
  claims: { openid: ["sub"], profile: ["name"], email: ["email", "email_verified"] },
  features: { clientCredentials: { enabled: true } },
  ttl: { AccessToken: 600, IdToken: 600, AuthorizationCode: 60, ClientCredentials: 600, RefreshToken: 86400, Interaction: 600, Session: 3600, Grant: 86400 },
  async findAccount(ctx, id) {
    const user = users[id];
    if (!user) return undefined;
    return { accountId: id, async claims() { return user; } };
  },
});

provider.listen(4100, () => {
  console.log("IdP: http://localhost:4100/.well-known/openid-configuration");
});
Test provider on port 4100: two clients, PKCE required for everyone, one user. In a real provider findAccount queries your database
.env
OIDC_ISSUER=http://localhost:4100
OIDC_CLIENT_ID=interlab-web
OIDC_CLIENT_SECRET=test-secret-change-me
WORKER_CLIENT_ID=reports-worker
WORKER_CLIENT_SECRET=test-worker-secret
Client settings. The file is not committed; in a real project the secret comes from the hosting environment

The client is a minimal node:http server, so a framework does not get in the way. /login creates the verifier, state and nonce, stores them under a random attempt id and sends the browser to the provider. /callback looks the values up by cookie, uses them exactly once and calls authorizationCodeGrant, which checks iss and state in the response, exchanges the code and validates the ID token.

app.mjs
// Relying party (the client): sign-in through an external provider.
// Run: node --env-file=.env app.mjs, then open http://localhost:4200/login
import { randomBytes } from "node:crypto";
import { createServer } from "node:http";
import * as client from "openid-client";

const REDIRECT_URI = "http://localhost:4200/callback";

// Discovery: endpoints and keys come from /.well-known/openid-configuration.
// allowInsecureRequests only because the test IdP runs on http://localhost.
const config = await client.discovery(
  new URL(process.env.OIDC_ISSUER),
  process.env.OIDC_CLIENT_ID,
  process.env.OIDC_CLIENT_SECRET,
  undefined,
  { execute: [client.allowInsecureRequests] },
);

// Pending sign-ins: transaction id -> PKCE verifier, state, nonce.
// In production keep this in the server-side session store, not in memory.
const pending = new Map();

function readCookie(req, name) {
  const pair = (req.headers.cookie ?? "").split("; ").find((c) => c.startsWith(`${name}=`));
  return pair?.slice(name.length + 1);
}

async function login(res) {
  const codeVerifier = client.randomPKCECodeVerifier();
  const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
  const state = client.randomState();
  const nonce = client.randomNonce();
  const tx = randomBytes(16).toString("base64url");
  pending.set(tx, { codeVerifier, state, nonce });

  const url = client.buildAuthorizationUrl(config, {
    redirect_uri: REDIRECT_URI,
    scope: "openid profile email",
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
    state,
    nonce,
  });
  // SameSite=Lax: the cookie is sent when the provider redirects the browser back (top-level GET).
  res.writeHead(302, {
    location: url.href,
    "set-cookie": `oidc_tx=${tx}; HttpOnly; SameSite=Lax; Path=/callback; Max-Age=600`,
  });
  res.end();
}

async function callback(req, res, currentUrl) {
  const tx = readCookie(req, "oidc_tx");
  const checks = tx && pending.get(tx);
  if (!checks) {
    res.writeHead(400).end("Unknown or expired sign-in attempt");
    return;
  }
  pending.delete(tx); // one attempt — one use

  // Checks iss in the response, state, exchanges code + code_verifier,
  // validates the ID token: iss, aud, exp, iat, nonce.
  const tokens = await client.authorizationCodeGrant(config, currentUrl, {
    pkceCodeVerifier: checks.codeVerifier,
    expectedState: checks.state,
    expectedNonce: checks.nonce,
  });
  const idToken = tokens.claims();
  // UserInfo is accepted only if its sub matches the ID token's sub.
  const profile = await client.fetchUserInfo(config, tokens.access_token, idToken.sub);

  console.log("token response:", {
    token_type: tokens.token_type,
    expires_in: tokens.expiresIn(),
    scope: tokens.scope,
    has_refresh_token: Boolean(tokens.refresh_token),
  });
  console.log("ID token claims:", idToken);
  console.log("UserInfo:", profile);

  // Stable user key = iss + sub (not email). Next step: create your own session.
  res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "set-cookie": "oidc_tx=; Path=/callback; Max-Age=0" });
  res.end(`Signed in: ${idToken.iss} / ${idToken.sub} (${profile.name})`);
}

createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost:4200");
  try {
    if (url.pathname === "/login") return await login(res);
    if (url.pathname === "/callback") return await callback(req, res, url);
    res.writeHead(404).end();
  } catch (err) {
    console.error("sign-in failed:", err.name, err.code ?? "", err.message);
    res.writeHead(400).end("Sign-in failed");
  }
}).listen(4200, () => console.log("App: http://localhost:4200/login"));
Relying Party on port 4200. allowInsecureRequests is there only because the test provider runs over http on localhost — it must not be used with a real provider

Start the provider and the client in two terminals, open http://localhost:4200/login, enter the login aigerim with any password and approve access. This is the browser's path — every redirect is visible:

terminal
302 GET  http://localhost:4200/login
303 GET  http://localhost:4100/auth?redirect_uri=http%3A%2F%2Flocalhost%3A4200%2Fcallback&scope=openid+profile+email&code_challenge=…&code_challenge_method=S256&state=…&nonce=…&client_id=interlab-web&response_type=code
200 GET  http://localhost:4100/interaction/rJBqRAwenOqCqEBf1z3I_h0OWVqZlzE3jj9MnzNQYRN
303 POST http://localhost:4100/interaction/rJBqRAwenOqCqEBf1z3I_h0OWVqZlzE3jj9MnzNQYRN
303 GET  http://localhost:4100/auth/rJBqRAwenOqCqEBf1z3I_h0OWVqZlzE3jj9MnzNQYRN
200 GET  http://localhost:4100/interaction/bSwi-VlD7oDKJ1I4g6ZjNDzuc8L2B6Wd_a2StkjfvEk
303 POST http://localhost:4100/interaction/bSwi-VlD7oDKJ1I4g6ZjNDzuc8L2B6Wd_a2StkjfvEk
303 GET  http://localhost:4100/auth/bSwi-VlD7oDKJ1I4g6ZjNDzuc8L2B6Wd_a2StkjfvEk
200 GET  http://localhost:4200/callback?code=…&state=…&iss=http%3A%2F%2Flocalhost%3A4100
The real request chain, recorded by a script playing the browser (code, state, nonce and challenge values hidden as «…»): sign-in, consent, return with code, state and iss
terminal
$ node provider.mjs
oidc-provider WARNING: a quick start development-only in-memory adapter is used, you MUST change it in order to not lose all stateful provider data upon restart and to be able to share these between processes
oidc-provider WARNING: a quick start development-only feature devInteractions is enabled, you are expected to disable these interactions and provide your own
IdP: http://localhost:4100/.well-known/openid-configuration

$ node --env-file=.env app.mjs
App: http://localhost:4200/login
token response: {
  token_type: 'bearer',
  expires_in: 599,
  scope: 'openid profile email',
  has_refresh_token: false
}
ID token claims: {
  sub: 'aigerim',
  nonce: '-LzAqMKyjMJKsyDq5VUnxw_j__msNftg7EGno4Fsg-k',
  aud: 'interlab-web',
  exp: 1791774176,
  iat: 1791773576,
  iss: 'http://localhost:4100'
}
UserInfo: {
  sub: 'aigerim',
  name: 'Aigerim Test',
  email: 'aigerim@example.test',
  email_verified: true
}
Real provider and client output. The oidc-provider warnings are a reminder that the in-memory store and the login form are for development only

Validating the ID token

OpenID Connect Core (section 3.1.3.7) lists what the client must check in an ID token. openid-client does it inside authorizationCodeGrant, but you should know the list — you will need it when the ID token does not come from the token endpoint but, for example, from a mobile app to your API.

  • iss exactly matches the issuer from discovery
  • aud contains your client_id and no untrusted extra audiences
  • the signature is verified with a key from the provider's jwks_uri, the algorithm is from an expected list, alg: none is rejected
  • the current time is before exp; iat is not too far in the past
  • nonce matches the value stored when the sign-in started
  • if max_age was requested, auth_time is checked

A subtlety worth knowing: when the ID token comes directly from the token endpoint over TLS, the specification allows TLS server validation to stand in for the signature check, and by default openid-client does not verify the signature in that case (enableNonRepudiationChecks turns it on). The other checks — iss, aud, exp, iat, nonce — the library always performs. When the token arrives any other way, the signature check is mandatory. This is the full check with jose:

verify-id-token.mjs
// Full ID token check when the token did NOT come straight from the token endpoint,
// e.g. a mobile app sends it to your API. OpenID Connect Core 1.0, section 3.1.3.7.
import { createRemoteJWKSet, jwtVerify } from "jose";

const jwksCache = new Map();

export async function verifyIdToken(idToken, { issuer, jwksUri, clientId, nonce }) {
  if (!jwksCache.has(jwksUri)) jwksCache.set(jwksUri, createRemoteJWKSet(new URL(jwksUri)));
  const { payload } = await jwtVerify(idToken, jwksCache.get(jwksUri), {
    issuer, // iss must match exactly
    audience: clientId, // aud must contain our client_id
    algorithms: ["RS256"], // only what we expect; never "none"
    requiredClaims: ["sub", "iat", "exp"],
    maxTokenAge: "10m", // reject tokens issued too long ago (iat)
    clockTolerance: 30, // seconds of clock skew allowed for exp/iat
  });
  if (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== clientId) {
    throw new Error("unexpected extra audiences");
  }
  if (payload.nonce !== nonce) throw new Error("nonce mismatch");
  return payload;
}
Validating an ID token that did not come from the token endpoint: JWKS keys are cached, the algorithm is pinned, the nonce is compared separately

The stand's tests run the full flow, tampering with one parameter at a time: a foreign verifier, a foreign state, a foreign nonce, a reused code, a request without PKCE, an unregistered redirect_uri — and, for the manual check, a forged sub, a foreign audience and alg: none.

terminal
$ node --test negative.test.mjs idtoken.test.mjs
  valid -> aigerim interlab-web
  forged sub -> ERR_JWS_SIGNATURE_VERIFICATION_FAILED
  other aud -> ERR_JWT_CLAIM_VALIDATION_FAILED aud
  nonce -> nonce mismatch
  alg none -> ERR_JOSE_ALG_NOT_ALLOWED
✔ ID token: valid passes, tampered / foreign aud / wrong nonce fail (119.2465ms)
  -> invalid_scope requested scope is not allowed
  -> invalid_client client authentication failed
✔ client credentials: scope outside the client's allowance is refused (28.9577ms)
✔ happy path returns an ID token for aigerim (86.1297ms)
  -> ResponseBodyError invalid_grant grant request is invalid
✔ wrong code_verifier: the IdP refuses the code (PKCE) (114.0837ms)
  -> ClientError OAUTH_INVALID_RESPONSE invalid response encountered
✔ state from another attempt is rejected before the token request (95.1324ms)
  -> ClientError OAUTH_JWT_CLAIM_COMPARISON_FAILED unexpected JWT claim value encountered
✔ unexpected nonce is rejected (109.1164ms)
  -> ResponseBodyError invalid_grant grant request is invalid
✔ the same code cannot be used twice (122.6895ms)
  -> 303 invalid_request Authorization Server policy requires PKCE to be used for this request
✔ authorization request without PKCE is refused by the IdP (2.7214ms)
  -> 400 null
✔ redirect_uri not registered: the IdP does not redirect at all (11.6301ms)
ℹ tests 9
ℹ pass 9
ℹ fail 0
Real node --test output on the stand (test code not shown: it repeats the app.mjs flow with tampered values)

Security mistakes: redirect_uri, state, implicit flow

Matching redirect_uri by prefix or pattern

Why it is dangerous
The code goes to the attacker's address, e.g. via an open redirect on your own domain
What to do instead
The authorization server compares the full string (RFC 9700, section 2.1); in the tests above the provider answers 400 and redirects nowhere

No state/PKCE, or constant values

Why it is dangerous
CSRF on the callback: the user gets «signed in» to the attacker's account
What to do instead
One-time values per attempt, bound to the browser session

Implicit flow in an SPA

Why it is dangerous
The token in the URL ends up in history, logs and Referer, and cannot be bound to the client
What to do instead
Authorization Code + PKCE, tokens via a backend (the BFF pattern) or kept in SPA memory

Password grant «for our own app»

Why it is dangerous
The password passes through the client, 2FA does not work, users learn to type passwords anywhere
What to do instead
Sign-in only on the provider's page

Signing in with an access token instead of an ID token

Why it is dangerous
A token issued to another application is accepted as proof of identity
What to do instead
Identity comes only from a validated ID token with your aud

Looking users up by email

Why it is dangerous
Account takeover when the provider reassigns the address
What to do instead
Key on iss + sub

Several providers without checking iss

Why it is dangerous
Mix-up attack: one provider's code is sent to another
What to do instead
Check iss in the response (RFC 9207) or in the ID token; openid-client does it automatically

In our experience most problems are not in the protocol but where it meets your own code: a callback that never checks whether the sign-in actually started in this browser; a session created before the ID token is validated; a client secret in the repository or in the frontend. How to store client secrets and give each integration its own least-privilege keys is covered in the integration security lesson.

How this was verified7 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.