OAuth 2.1 and OpenID Connect Implementation

·19 min read·Securityintermediate

Modern auth is finally cleaning up its act, and it matters now because the attack surface is bigger than ever.

Diagram showing an OAuth 2.1 flow with a client, authorization server, and resource server, including consent and token exchange steps

I spent a few years adding and removing authentication flows in production apps. At first, every new project looked the same: a login page, a token somewhere, and a vague promise that "the library will handle the rest." Then the incidents started: token leakage via redirect_uri misconfigurations, replayable refresh tokens, CSRF on the login callback, and confusion about which spec variant we were actually using. The ecosystem is maturing, but if you are implementing or maintaining an auth system today, you need to be deliberate. OAuth 2.1 is a consolidation that removes footguns. OpenID Connect (OIDC) layers identity on top of it. When used together, they provide a clean, consistent way to secure modern apps and APIs without reinventing wheels.

In this post, I will walk through what OAuth 2.1 and OIDC are, why they matter right now, and how to implement them with practical patterns and code you can adapt. I will not attempt an exhaustive reference; instead, I will focus on real-world decisions and common pitfalls I have encountered or watched play out in teams. If you are new to OAuth, I will point out where the subtle traps hide. If you are experienced, I will show how to align with 2.1’s refinements so you stop carrying legacy baggage.

Context: Where OAuth 2.1 and OIDC fit today

OAuth 2.1 is a proposed best-practice profile of OAuth 2.0, aiming to remove insecure options and align with RFCs that matter for security. It draws from RFC 6749, RFC 6819 (security considerations), RFC 7636 (PKCE), RFC 8414 (authorization server metadata), RFC 8705 (MTLS for client auth), and BCP 88 (refresh token best practices). It also incorporates lessons from OAuth 2.0 for Browser-Based Apps and OAuth 2.0 for Native Apps. In practice, OAuth 2.1 mandates PKCE for public clients, requires confidential client authentication for confidential clients, and deprecates the resource owner password grant. It clarifies requirements around redirect URIs, refresh token rotation, and sender-constrained tokens.

OpenID Connect layers identity on top of OAuth 2.0 and, by extension, OAuth 2.1. OIDC adds ID tokens (JWT), the userinfo endpoint, and standardized claims. It specifies discovery via OIDC well-known configuration and dynamic client registration where appropriate. Many organizations use an OIDC provider as their Authorization Server, exposing OAuth endpoints and an identity layer simultaneously.

In the real world, OAuth and OIDC are used to secure:

  • SPAs and server-rendered web apps (OAuth 2.1 for Browser-Based Apps).
  • Native mobile apps (OAuth 2.1 for Native Apps with universal links/app links).
  • Microservices and APIs (token introspection or JWT validation, mutual TLS, and sender-constrained tokens).
  • Machine-to-machine flows (client credentials, often paired with MTLS or private_key_jwt).

Alternatives remain in niche scenarios: SAML for enterprise federation in legacy ecosystems; API keys for internal, low-risk services; and custom auth for very small projects. But for most modern stacks, OIDC atop OAuth 2.1 is the de facto standard due to strong library support, security baselines, and interoperability with major IdPs (Auth0, Okta, Keycloak, Azure AD/Entra, Google, Amazon Cognito).

Core concepts: The guardrails you actually want

OAuth 2.1 roles and the mindset shift

  • Resource owner: The user or system that owns the data.
  • Client: The app requesting access. Public clients (SPAs, native apps) cannot keep secrets; confidential clients (server-side apps) can.
  • Authorization server (AS): Issues tokens, manages consent, and exposes endpoints.
  • Resource server (RS): Validates tokens and serves data.

OAuth is about delegated access, not authentication. OIDC adds identity via ID tokens and the userinfo endpoint, which is what you use to log a user in. In practice, you combine them: use OAuth flows to get tokens, and OIDC to authenticate the user.

The OAuth 2.1 mandatory guardrails

  • PKCE (RFC 7636) is required for public clients. This prevents code interception attacks during authorization code exchange.
  • Redirect URIs are registered strictly. Wildcards are not allowed. For native apps, use platform-specific URIs (app links, universal links) or loopback addresses with exact ports.
  • Refresh tokens must be rotated and bound to the client where possible (sender-constrained via MTLS or private_key_jwt).
  • Resource owner password grant is deprecated. Use authorization code + PKCE or device flow instead.
  • Client authentication for confidential clients uses MTLS (RFC 8705) or private_key_jwt (RFC 7523) where possible; plain client_secret is discouraged in hostile environments.

OIDC core for identity

  • ID token (JWT): Asserts authentication. Validate signature, audience, issuer, and nonce.
  • Access token: Used to call APIs (opaque or JWT).
  • Refresh token: Used to obtain new access tokens without re-authenticating the user.
  • Discovery: OIDC well-known endpoint provides configuration (issuer, jwks_uri, authorization_endpoint, token_endpoint, userinfo_endpoint).
  • Claims: Standard claims (sub, email, name) via ID token or userinfo; scopes (openid, profile, email) drive what you get.

A helpful mental model: OAuth 2.1 is the secure rail system for token exchange; OIDC is the passenger manifest telling you who just got on.

Practical implementation: Patterns and code

Project structure for a Node.js confidential web app

Here is a minimal folder structure for a server-side web app that uses OAuth 2.1 with OIDC. I have chosen Node.js with Express for clarity. In real projects, you might split the auth module further or use a dedicated SDK. The point is to separate concerns: routes, auth service, token storage, and API client.

my-app/
├─ src/
│  ├─ routes/
│  │  ├─ auth.js
│  │  ├─ protected.js
│  │  └─ webhook.js
│  ├─ services/
│  │  ├─ auth.js
│  │  └─ token-store.js
│  ├─ lib/
│  │  ├─ oidc-client.js
│  │  └─ jwt.js
│  └─ app.js
├─ config/
│  ├─ default.json
│  └─ .env
├─ scripts/
│  └─ rotate-keys.js
├─ public/
│  └─ index.html
├─ .gitignore
├─ package.json
└─ README.md

Configuration using environment variables

Avoid hardcoding secrets. Use a .env file in development; in production, use a secret manager. The configuration covers endpoints that you discover from the OIDC provider’s well-known configuration. For example, Auth0 exposes https://YOUR_DOMAIN/.well-known/openid-configuration. The same pattern applies to Keycloak, Okta, or Azure AD.

// config/default.json
{
  "oidc": {
    "issuer": "https://dev-abc123.us.auth0.com",
    "clientId": "${CLIENT_ID}",
    "clientSecret": "${CLIENT_SECRET}",
    "redirectUri": "https://app.example.com/callback",
    "scope": "openid profile email offline_access",
    "tokenEndpointAuthMethod": "client_secret_basic"
  },
  "session": {
    "secret": "${SESSION_SECRET}"
  },
  "api": {
    "audience": "https://api.example.com",
    "baseUrl": "https://api.example.com/v1"
  }
}

OIDC discovery and client initialization

Start by fetching discovery metadata to get endpoints and the JWKS URI. This makes your client resilient across environments. Then configure your HTTP client to attach tokens and handle token refresh.

// src/lib/oidc-client.js
const axios = require('axios');
const jose = require('jose');
const crypto = require('crypto');

class OIDCClient {
  constructor(config) {
    this.config = config;
    this.issuer = config.oidc.issuer;
    this.metadata = null;
    this.jwks = null;
  }

  async discover() {
    const wellKnownUrl = `${this.issuer}/.well-known/openid-configuration`;
    const res = await axios.get(wellKnownUrl, { timeout: 5000 });
    this.metadata = res.data;
    return this.metadata;
  }

  async loadJwks() {
    if (!this.metadata) await this.discover();
    const jwksUri = this.metadata.jwks_uri;
    const res = await axios.get(jwksUri, { timeout: 5000 });
    this.jwks = res.data;
    return this.jwks;
  }

  // Build authorization URL for code flow with PKCE
  buildAuthUrl(state, codeVerifier) {
    const codeChallenge = crypto
      .createHash('sha256')
      .update(codeVerifier)
      .digest('base64url');

    const params = new URLSearchParams({
      client_id: this.config.oidc.clientId,
      response_type: 'code',
      redirect_uri: this.config.oidc.redirectUri,
      scope: this.config.oidc.scope,
      state,
      code_challenge: codeChallenge,
      code_challenge_method: 'S256',
      // Include audience for APIs that require it (Auth0 style)
      audience: this.config.api.audience,
    });

    return `${this.metadata.authorization_endpoint}?${params.toString()}`;
  }

  // Exchange authorization code for tokens (confidential client)
  async exchangeCodeForTokens(code, codeVerifier) {
    const basicAuth = Buffer.from(
      `${this.config.oidc.clientId}:${this.config.oidc.clientSecret}`
    ).toString('base64');

    const body = new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: this.config.oidc.redirectUri,
      code_verifier: codeVerifier,
    });

    const res = await axios.post(
      this.metadata.token_endpoint,
      body.toString(),
      {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          Authorization: `Basic ${basicAuth}`,
        },
        timeout: 10000,
      }
    );

    return res.data; // { access_token, id_token, refresh_token, expires_in }
  }

  // Refresh tokens without user interaction (confidential client)
  async refreshAccessToken(refreshToken) {
    const basicAuth = Buffer.from(
      `${this.config.oidc.clientId}:${this.config.oidc.clientSecret}`
    ).toString('base64');

    const body = new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
    });

    const res = await axios.post(
      this.metadata.token_endpoint,
      body.toString(),
      {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          Authorization: `Basic ${basicAuth}`,
        },
        timeout: 10000,
      }
    );

    return res.data;
  }

  // Validate ID token (signature + claims)
  async validateIdToken(idToken, nonce) {
    if (!this.jwks) await this.loadJwks();

    const keyStore = jose.createRemoteJWKSet(new URL(this.metadata.jwks_uri));
    const { payload, protectedHeader } = await jose.compactDecrypt(idToken, keyStore);

    const claims = payload;
    const expectedIssuer = this.issuer.endsWith('/') ? this.issuer.slice(0, -1) : this.issuer;
    if (claims.iss !== expectedIssuer) {
      throw new Error('Issuer mismatch');
    }

    const clientId = this.config.oidc.clientId;
    if (!claims.aud || !claims.aud.includes(clientId)) {
      throw new Error('Audience mismatch');
    }

    const now = Math.floor(Date.now() / 1000);
    if (claims.exp && claims.exp < now) {
      throw new Error('Token expired');
    }
    if (claims.iat && claims.iat > now) {
      throw new Error('Token issued in the future');
    }
    if (nonce && claims.nonce !== nonce) {
      throw new Error('Nonce mismatch');
    }

    return { claims, header: protectedHeader };
  }
}

module.exports = OIDCClient;

Token storage with rotation and minimal state

In server-side apps, store tokens server-side in encrypted cookies or server-side sessions. Do not persist refresh tokens in browsers. For resilience, store refresh tokens in a secure store (e.g., server-side session or a database with encryption at rest) and rotate them on use. OAuth 2.1 encourages rotation to detect theft. Here is a simple in-memory store for demonstration; in production, use a persistent, encrypted store.

// src/services/token-store.js
const crypto = require('crypto');

class TokenStore {
  constructor() {
    this.sessions = new Map();
  }

  // Store tokens for a session; in production, encrypt the refresh token
  set(sessionId, tokens, claims) {
    const refreshToken = tokens.refresh_token;
    const rotationToken = crypto.randomBytes(32).toString('hex');
    this.sessions.set(sessionId, {
      accessToken: tokens.access_token,
      idToken: tokens.id_token,
      refreshToken,
      rotationToken,
      expiresAt: Date.now() + (tokens.expires_in ? tokens.expires_in * 1000 : 3600000),
      claims,
    });
  }

  get(sessionId) {
    return this.sessions.get(sessionId);
  }

  // Rotate refresh token: invalidate previous, store new one
  rotate(sessionId, newTokens) {
    const current = this.sessions.get(sessionId);
    if (!current) throw new Error('Session not found');

    this.sessions.set(sessionId, {
      accessToken: newTokens.access_token,
      idToken: newTokens.id_token,
      refreshToken: newTokens.refresh_token,
      rotationToken: crypto.randomBytes(32).toString('hex'),
      expiresAt: Date.now() + (newTokens.expires_in ? newTokens.expires_in * 1000 : 3600000),
      claims: current.claims,
    });
  }

  clear(sessionId) {
    this.sessions.delete(sessionId);
  }
}

module.exports = TokenStore;

Express routes for authorization flow

The routes below implement the OAuth 2.1 authorization code flow with PKCE for a confidential client. They generate a state and code verifier per session to prevent CSRF and code interception. After callback, the ID token is validated, and tokens are stored server-side. The example also shows a minimal API call using the access token.

// src/routes/auth.js
const express = require('express');
const crypto = require('crypto');
const OIDCClient = require('../lib/oidc-client');
const TokenStore = require('../services/token-store');

const router = express.Router();
const oidc = new OIDCClient(require('config'));
const tokens = new TokenStore();

// Login: redirect to authorization server
router.get('/login', async (req, res) => {
  // Start a session
  const sessionId = crypto.randomBytes(16).toString('hex');
  res.cookie('session', sessionId, { httpOnly: true, secure: true, sameSite: 'lax' });

  // Generate state and PKCE verifier
  const state = crypto.randomBytes(16).toString('hex');
  const codeVerifier = crypto.randomBytes(32).toString('hex');

  // Persist temporary state to validate later
  tokens.set(sessionId, { access_token: '', id_token: '', refresh_token: '' }, {});
  // In real apps, persist state and verifier in server-side store linked to session
  req.session.state = state;
  req.session.verifier = codeVerifier;

  const authUrl = oidc.buildAuthUrl(state, codeVerifier);
  res.redirect(authUrl);
});

// Callback: exchange code, validate ID token, store tokens
router.get('/callback', async (req, res) => {
  const { code, state } = req.query;
  const sessionId = req.cookies.session;
  const storedState = req.session.state;
  const verifier = req.session.verifier;

  if (!code || !state || state !== storedState) {
    return res.status(400).send('Invalid request');
  }

  try {
    const tokenSet = await oidc.exchangeCodeForTokens(code, verifier);
    const { claims } = await oidc.validateIdToken(tokenSet.id_token, req.session.nonce);

    // Store tokens server-side (never send refresh token to browser)
    tokens.set(sessionId, tokenSet, claims);

    // Clear temporary state
    delete req.session.state;
    delete req.session.verifier;
    delete req.session.nonce;

    res.redirect('/protected');
  } catch (err) {
    console.error('Callback error:', err.message);
    res.status(500).send('Authentication failed');
  }
});

// Logout: clear session and revoke tokens if possible
router.post('/logout', (req, res) => {
  const sessionId = req.cookies.session;
  tokens.clear(sessionId);
  res.clearCookie('session');
  res.redirect('/');
});

module.exports = router;

Protecting routes and calling APIs

Protect routes by checking that the session exists and the access token is not expired. For API calls, attach the token in the Authorization header. If the token is expired, attempt a refresh. If refresh fails, redirect to login.

// src/routes/protected.js
const express = require('express');
const axios = require('axios');
const OIDCClient = require('../lib/oidc-client');
const TokenStore = require('../services/token-store');

const router = express.Router();
const oidc = new OIDCClient(require('config'));
const tokens = new TokenStore();

// Middleware: require valid session and non-expired access token
async function requireAuth(req, res, next) {
  const sessionId = req.cookies.session;
  const session = tokens.get(sessionId);

  if (!session) {
    return res.redirect('/login');
  }

  const now = Date.now();
  if (now >= session.expiresAt) {
    try {
      // Refresh token rotation
      const refreshed = await oidc.refreshAccessToken(session.refreshToken);
      tokens.rotate(sessionId, refreshed);
      return next();
    } catch (err) {
      console.error('Refresh failed:', err.message);
      tokens.clear(sessionId);
      res.clearCookie('session');
      return res.redirect('/login');
    }
  }

  next();
}

router.get('/protected', requireAuth, async (req, res) => {
  const sessionId = req.cookies.session;
  const session = tokens.get(sessionId);

  // Call API with access token
  try {
    const apiRes = await axios.get(`${require('config').api.baseUrl}/me`, {
      headers: { Authorization: `Bearer ${session.accessToken}` },
      timeout: 5000,
    });

    res.send(`
      <h1>Protected page</h1>
      <p>Hello, ${session.claims.name || session.claims.email || session.claims.sub}.</p>
      <pre>${JSON.stringify(apiRes.data, null, 2)}</pre>
      <form method="post" action="/logout"><button>Logout</button></form>
    `);
  } catch (err) {
    console.error('API call failed:', err.message);
    res.status(500).send('API error');
  }
});

module.exports = router;

App entry point and session setup

The app uses secure cookies and server-side sessions for tokens. In production, use a proper session store (e.g., Redis) and TLS everywhere. For demonstration, we use cookie-session.

// src/app.js
const express = require('express');
const cookieSession = require('cookie-session');
const cookieParser = require('cookie-parser');
const authRoutes = require('./routes/auth');
const protectedRoutes = require('./routes/protected');

const app = express();

app.use(cookieParser());
app.use(
  cookieSession({
    name: 'app_session',
    secret: process.env.SESSION_SECRET || 'dev-secret',
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 24 * 60 * 60 * 1000,
  })
);

app.use(express.urlencoded({ extended: false }));

app.use(authRoutes);
app.use(protectedRoutes);

app.get('/', (req, res) => {
  res.send(`
    <h1>Home</h1>
    <p>OAuth 2.1 + OIDC demo</p>
    <a href="/login">Login</a>
  `);
});

app.listen(3000, () => {
  console.log('App listening on http://localhost:3000');
});

Real-world error handling patterns

Handling errors in auth flows must be robust. Common issues include network timeouts, invalid grants, replayed requests, and clock skew. The following service wrapper centralizes error classification.

// src/lib/errors.js
class AuthError extends Error {
  constructor(type, message, status = 400) {
    super(message);
    this.type = type;
    this.status = status;
  }

  static invalidRequest(message) {
    return new AuthError('invalid_request', message, 400);
  }

  static invalidGrant(message) {
    return new AuthError('invalid_grant', message, 400);
  }

  static serverError(message) {
    return new AuthError('server_error', message, 500);
  }
}

module.exports = { AuthError };

Example using client credentials for machine-to-machine

For M2M, OAuth 2.1 recommends confidential client authentication (MTLS or private_key_jwt). If you must use client_secret, do it over TLS and rotate secrets. This example shows a service calling a protected API using client credentials.

// scripts/m2m-demo.js
const axios = require('axios');
const config = require('config');

async function getClientCredentialsToken() {
  const basicAuth = Buffer.from(`${config.oidc.clientId}:${config.oidc.clientSecret}`).toString('base64');

  const body = new URLSearchParams({
    grant_type: 'client_credentials',
    audience: config.api.audience,
    scope: 'read:reports write:reports',
  });

  const res = await axios.post(
    `${config.oidc.issuer}/oauth/token`,
    body.toString(),
    {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        Authorization: `Basic ${basicAuth}`,
      },
    }
  );

  return res.data.access_token;
}

async function callApi() {
  const token = await getClientCredentialsToken();
  const res = await axios.get(`${config.api.baseUrl}/reports`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  console.log(res.data);
}

callApi().catch(console.error);

Honest evaluation: Strengths, weaknesses, tradeoffs

Strengths

  • Security baseline: OAuth 2.1 removes insecure grants and mandates PKCE for public clients, reducing code interception and replay risks.
  • Interoperability: OIDC and OAuth are supported by major IdPs and a mature library ecosystem. You can switch providers with minimal code changes if you rely on standards.
  • Fine-grained access: Scopes and audience separation help protect APIs and microservices.
  • Modern patterns: Rotation, sender-constrained tokens, and discovery simplify maintenance and harden token lifecycles.

Weaknesses and tradeoffs

  • Complexity: The flows, discovery, JWKS handling, and token validation can be challenging to implement correctly from scratch. Bugs here are high impact.
  • User experience: Consent screens and redirects can be jarring if not integrated thoughtfully. Mobile flows require platform-specific setup (app links).
  • Operational burden: Key rotation, JWKS updates, token revocation, and monitoring of token misuse require reliable infrastructure.
  • Not authentication by default: If you forget OIDC’s ID token, you might be delegating access without logging the user in. That is a common mistake.

When to use

  • Use OAuth 2.1 + OIDC for web apps, SPAs (with BFF patterns), native apps, microservices, and M2M.
  • Consider alternatives if you have a tightly coupled, single-service monolith with no API calls and only basic session auth; session cookies alone may suffice. But if you plan to add APIs or federate later, OIDC will save time.

When to skip

  • If your users are internal and you rely on a corporate IdP with SAML only, you might start with SAML and migrate toward OIDC gradually.
  • If you have extreme low-resource devices that cannot handle JWT validation, consider lighter token formats or gateway-based auth. This is niche.

Personal experience: Lessons learned

I learned OAuth the hard way by implementing an auth flow without PKCE for a native app and discovering later that a malicious app could intercept the code on some older Android versions. Adding PKCE was simple, but the retrofit required coordination with mobile teams and QA to test edge scenarios. Another time, I misread the OIDC spec and assumed the ID token was sufficient for API authorization. It is not. Access tokens are for APIs; ID tokens are for identity. Mixing them led to subtle bugs where some endpoints accepted ID tokens while others silently failed. We consolidated on using access tokens for APIs and ID tokens only to authenticate the user on login, and everything became clearer.

Refresh token rotation has been a lifesaver. On one project, we noticed unexpected refresh token reuse; because we rotated tokens and tracked rotation counters, we detected a compromised client and revoked the session. Without rotation, the leak would have been invisible. The fix required adding an alerting path and a revocation endpoint call, but it paid off.

My most significant learning: treat the authorization server as a critical dependency. It needs observability. Log token issuance, refresh, and validation failures. Monitor for spikes in invalid_grant and invalid_request errors. They often indicate misconfigured clients or attempted abuse.

Getting started: Workflow and mental models

Your workflow

  • Choose an OIDC provider (Auth0, Okta, Keycloak, Azure AD/Entra, Google, Amazon Cognito).
  • Register your client: decide public vs. confidential, set exact redirect URIs, and choose token auth method (client_secret_basic, private_key_jwt, MTLS).
  • Discover endpoints: fetch the well-known OIDC configuration and JWKS URI.
  • Implement authorization code flow with PKCE for public clients. For confidential clients, still use PKCE if possible; it adds defense in depth.
  • Validate ID tokens (issuer, audience, nonce, signature) and use access tokens for APIs.
  • Implement refresh with rotation; store refresh tokens securely on the server; do not expose them to browsers.
  • Plan for key rotation and JWKS updates; do not hardcode keys.
  • Add observability and error handling paths; simulate failures.

Project setup outline

  • Initialize a server-side app (Node.js, Python, Java, .NET) with HTTPS locally.
  • Add session management with secure cookies.
  • Add an HTTP client with timeouts and retries.
  • Integrate an OIDC client library if available; if building from scratch, follow the code patterns above.
  • Create a protected route and an API client that attaches tokens and handles refresh.
# Example project setup in a Node.js environment
# 1. Initialize
npm init -y

# 2. Install dependencies
npm i express cookie-session cookie-parser axios jose config dotenv

# 3. Add .env with real values
# CLIENT_ID=your-client-id
# CLIENT_SECRET=your-client-secret
# SESSION_SECRET=strong-random-secret
# NODE_ENV=production

# 4. Run the app (development only)
# node src/app.js

# 5. Test with a real OIDC provider by replacing issuer in config/default.json

What makes this approach stand out

  • Standards-based interoperability means you can swap IdPs with minimal code changes.
  • Secure defaults reduce the chance of regressions when you add new clients or APIs.
  • Separation of concerns (routes, auth service, token store) keeps code maintainable.
  • Observability and rotation are baked into the pattern, not bolted on later.

Free learning resources

Summary and who should use this

OAuth 2.1 and OIDC are best used by teams building modern apps with multiple clients and APIs, especially where identity and delegated access intersect. If you are a solo developer building a simple monolith with no APIs, you might start with session cookies and adopt OIDC when you grow. If you are building SPAs, native apps, microservices, or integrating with enterprise SSO, these standards are the right foundation.

Who might skip it? Embedded systems with no network, very small internal tools with no API boundaries, or legacy systems tightly coupled to SAML where migration is out of scope for now. Everyone else should consider OAuth 2.1 and OIDC as the pragmatic default.

The takeaway: the specs are not just paperwork. They encode years of incident learnings. Implement the guardrails, watch your error logs, and plan for rotation and key management. Your future self will thank you during your next security review.