Security and resilience

OAuth 2.1 Authorization Server: practical patterns for enterprise implementations

OAuth 2.1 simplifies previous RFCs by removing insecure flows and standardizing practices. Implementing a production-ready authorization server requires understanding PKCE, proof-key-for-code-exchange, refresh tokens, and enterprise IDP integrations.

3/12/20269 min readSecurity
OAuth 2.1 Authorization Server: practical patterns for enterprise implementations

Executive summary

OAuth 2.1 simplifies previous RFCs by removing insecure flows and standardizing practices. Implementing a production-ready authorization server requires understanding PKCE, proof-key-for-code-exchange, refresh tokens, and enterprise IDP integrations.

Last updated: 3/12/2026

What changed from OAuth 2.0 to OAuth 2.1

OAuth 2.1 (published as draft RFC in 2024, consolidated in 2025) simplifies and secures OAuth 2.0 by removing problematic flows and standardizing practices that were previously "should be." For security and auth engineers, the most important change is that OAuth 2.1 makes mandatory what was recommended.

Key changes:

  • Authorization Code Flow with PKCE is now mandatory for all clients (not just public)
  • Implicit Flow has been completely removed (obsolete since 2016)
  • Resource Owner Password Credentials Grant has been removed
  • Refresh Token Rotation is strongly recommended
  • Token binding via TLS certificate is supported

Authorization Code Flow with PKCE

Why PKCE is mandatory

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. Even in confidential applications (backend-to-backend), OAuth 2.1 requires PKCE to mitigate code injection attacks.

Complete flow with PKCE

typescript// 1. Client generates code verifier and challenge
import { createHash, randomBytes } from 'crypto';

function generateCodeVerifier(): string {
  return randomBytes(32).toString('base64url');
}

function generateCodeChallenge(verifier: string): string {
  const hash = createHash('sha256').update(verifier).digest();
  return hash.toString('base64url');
}

const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);

// Store codeVerifier temporarily for exchange step
session.codeVerifier = codeVerifier;
typescript// 2. Redirect user to authorization endpoint
const authUrl = new URL('https://auth.example.com/oauth/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'my-client-id');
authUrl.searchParams.set('redirect_uri', 'https://myapp.com/callback');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('scope', 'openid profile email offline_access');
authUrl.searchParams.set('state', generateState()); // CSRF protection

window.location.href = authUrl.toString();
typescript// 3. Exchange authorization code for tokens
async function exchangeCodeForTokens(code: string) {
  const response = await fetch('https://auth.example.com/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'Authorization': `Basic ${btoa('my-client-id:client-secret')}`
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: 'https://myapp.com/callback',
      code_verifier: session.codeVerifier,
      client_id: 'my-client-id'
    })
  });

  const tokens = await response.json();
  return tokens;
}

Authorization server implementation

typescript// Authorization endpoint (simplified)
app.get('/oauth/authorize', async (req, res) => {
  const { response_type, client_id, redirect_uri, code_challenge, code_challenge_method, state, scope } = req.query;

  // Validate client
  const client = await clientStore.findById(client_id);
  if (!client || !client.redirectUris.includes(redirect_uri)) {
    return res.status(400).json({ error: 'invalid_request' });
  }

  // Validate PKCE parameters
  if (!code_challenge || code_challenge_method !== 'S256') {
    return res.status(400).json({ error: 'invalid_request' });
  }

  // Store authorization code with challenge
  const authCode = generateSecureRandom();
  await authCodeStore.save(authCode, {
    clientId: client_id,
    codeChallenge: code_challenge,
    codeChallengeMethod: code_challenge_method,
    userId: req.user.id,
    expiresAt: new Date(Date.now() + 10 * 60 * 1000) // 10 minutes
  });

  // Show consent page (if needed)
  return res.render('consent', {
    client: client,
    scope: scope,
    authCode: authCode,
    state: state
  });
});

// Token endpoint
app.post('/oauth/token', async (req, res) => {
  const { grant_type, code, redirect_uri, code_verifier, client_id } = req.body;

  // Retrieve and validate auth code
  const authCodeData = await authCodeStore.findAndDelete(code);
  if (!authCodeData || authCodeData.expiresAt < new Date()) {
    return res.status(400).json({ error: 'invalid_grant' });
  }

  // Validate PKCE
  const expectedChallenge = createHash('sha256')
    .update(code_verifier)
    .digest()
    .toString('base64url');

  if (authCodeData.codeChallenge !== expectedChallenge) {
    return res.status(400).json({ error: 'invalid_grant' });
  }

  // Generate tokens
  const accessToken = jwt.sign(
    { sub: authCodeData.userId, scope: authCodeData.scope },
    config.jwtSecret,
    { expiresIn: '1h' }
  );

  const refreshToken = generateRefreshToken(authCodeData.userId, client_id);

  res.json({
    access_token: accessToken,
    token_type: 'Bearer',
    expires_in: 3600,
    refresh_token: refreshToken,
    scope: authCodeData.scope
  });
});

Refresh Token Rotation

Refresh tokens are targets for theft attacks. OAuth 2.1 recommends refresh token rotation: each new refresh token invalidates the previous one.

Refresh token rotation implementation

typescriptinterface RefreshToken {
  id: string;
  userId: string;
  clientId: string;
  token: string;
  parentToken?: string; // For tracking rotation chain
  revoked: boolean;
  expiresAt: Date;
  lastUsedAt: Date;
}

// Token endpoint - refresh token grant
app.post('/oauth/token', async (req, res) => {
  const { grant_type, refresh_token } = req.body;

  if (grant_type !== 'refresh_token') {
    return handleOtherGrantTypes(req, res);
  }

  // Validate refresh token
  const storedToken = await refreshTokenStore.findByToken(refresh_token);
  if (!storedToken || storedToken.revoked || storedToken.expiresAt < new Date()) {
    return res.status(400).json({ error: 'invalid_grant' });
  }

  // Detect token reuse (possible attack)
  if (storedToken.parentToken) {
    // Parent token already used, indicating possible reuse
    await refreshTokenStore.revokeFamily(storedToken.id);
    await sessionStore.revokeAllUserSessions(storedToken.userId);
    return res.status(400).json({ error: 'invalid_grant' });
  }

  // Generate new access token
  const accessToken = jwt.sign(
    { sub: storedToken.userId, scope: storedToken.scope },
    config.jwtSecret,
    { expiresIn: '1h' }
  );

  // Generate new refresh token
  const newRefreshToken = generateSecureRandom();
  await refreshTokenStore.save({
    id: generateId(),
    userId: storedToken.userId,
    clientId: storedToken.clientId,
    token: newRefreshToken,
    parentToken: storedToken.id, // Track rotation
    revoked: false,
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
    lastUsedAt: new Date()
  });

  // Revoke old refresh token
  await refreshTokenStore.revoke(storedToken.id);

  res.json({
    access_token: accessToken,
    token_type: 'Bearer',
    expires_in: 3600,
    refresh_token: newRefreshToken,
    scope: storedToken.scope
  });
});

// Token store with rotation tracking
class RefreshTokenStore {
  async save(token: RefreshToken): Promise<void> {
    await db.refreshTokens.insert(token);
  }

  async findByToken(token: string): Promise<RefreshToken | null> {
    return await db.refreshTokens.findOne({ token, revoked: false });
  }

  async revoke(tokenId: string): Promise<void> {
    await db.refreshTokens.updateOne(
      { id: tokenId },
      { revoked: true }
    );
  }

  async revokeFamily(tokenId: string): Promise<void> {
    // Revoke all tokens in rotation chain
    const token = await db.refreshTokens.findOne({ id: tokenId });
    if (token.parentToken) {
      await this.revokeFamily(token.parentToken);
    }
    await this.revoke(tokenId);
    await db.refreshTokens.updateMany(
      { parentToken: tokenId },
      { revoked: true }
    );
  }

  async revokeAllUserSessions(userId: string): Promise<void> {
    await db.refreshTokens.updateMany(
      { userId },
      { revoked: true }
    );
  }
}

OpenID Connect (OIDC) Configuration

OIDC is an identity layer over OAuth 2.1, adding userinfo endpoints, ID tokens, and discovery.

Discovery endpoint

typescript// /.well-known/openid-configuration
app.get('/.well-known/openid-configuration', (req, res) => {
  const baseUrl = `${req.protocol}://${req.get('host')}`;

  res.json({
    issuer: baseUrl,
    authorization_endpoint: `${baseUrl}/oauth/authorize`,
    token_endpoint: `${baseUrl}/oauth/token`,
    userinfo_endpoint: `${baseUrl}/oauth/userinfo`,
    jwks_uri: `${baseUrl}/.well-known/jwks.json`,
    response_types_supported: ['code'],
    subject_types_supported: ['public'],
    id_token_signing_alg_values_supported: ['RS256', 'ES256'],
    scopes_supported: ['openid', 'profile', 'email', 'offline_access'],
    claims_supported: [
      'sub', 'name', 'given_name', 'family_name', 'email',
      'email_verified', 'picture', 'updated_at'
    ],
    code_challenge_methods_supported: ['S256'],
    grant_types_supported: ['authorization_code', 'refresh_token'],
    token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post']
  });
});

// JWKS endpoint (public keys for token verification)
app.get('/.well-known/jwks.json', (req, res) => {
  res.json({
    keys: [
      {
        kty: 'RSA',
        use: 'sig',
        alg: 'RS256',
        n: keyPair.publicKey.n,
        e: keyPair.publicKey.e,
        kid: 'key-1'
      }
    ]
  });
});

ID Token generation

typescriptfunction generateIdToken(userId: string, authCodeData: AuthCode, nonce?: string): string {
  const now = Math.floor(Date.now() / 1000);

  const payload = {
    iss: config.issuer,
    sub: userId,
    aud: authCodeData.clientId,
    exp: now + 3600,
    iat: now,
    nonce: nonce,
    auth_time: Math.floor(Date.now() / 1000),
    email: authCodeData.userEmail,
    email_verified: authCodeData.userEmailVerified
  };

  return jwt.sign(payload, keyPair.privateKey, { algorithm: 'RS256', keyid: 'key-1' });
}

Userinfo endpoint

typescriptapp.get('/oauth/userinfo', authenticateBearerToken, async (req, res) => {
  const userId = req.token.sub;
  const user = await userStore.findById(userId);

  if (!user) {
    return res.status(404).json({ error: 'user_not_found' });
  }

  res.json({
    sub: user.id,
    name: user.name,
    given_name: user.givenName,
    family_name: user.familyName,
    email: user.email,
    email_verified: user.emailVerified,
    picture: user.picture,
    updated_at: user.updatedAt
  });
});

Device Authorization Flow

Device Authorization Flow enables authentication on devices with limited input (Smart TVs, IoT, CLIs).

Authorization endpoint

typescriptapp.post('/oauth/device_authorization', async (req, res) => {
  const { client_id, scope } = req.body;

  const userCode = generateUserCode(); // Ex: "ABCD-EFGH"
  const deviceCode = generateSecureRandom();
  const verificationUri = `https://auth.example.com/device`;

  await deviceCodeStore.save({
    deviceCode,
    userCode,
    clientId: client_id,
    scope: scope || 'openid profile',
    expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
    pollingInterval: 5, // 5 seconds
    interval: 5,
    status: 'pending'
  });

  res.json({
    device_code: deviceCode,
    user_code: userCode,
    verification_uri: verificationUri,
    verification_uri_complete: `${verificationUri}?user_code=${userCode}`,
    expires_in: 900,
    interval: 5
  });
});

Token endpoint (device grant)

typescriptapp.post('/oauth/token', async (req, res) => {
  const { grant_type, device_code, client_id } = req.body;

  if (grant_type === 'urn:ietf:params:oauth:grant-type:device_code') {
    const deviceAuth = await deviceCodeStore.findByDeviceCode(device_code);

    if (!deviceAuth || deviceAuth.expiresAt < new Date()) {
      return res.status(400).json({ error: 'expired_token' });
    }

    if (deviceAuth.status === 'pending') {
      return res.status(400).json({
        error: 'authorization_pending',
        interval: deviceAuth.interval
      });
    }

    if (deviceAuth.status === 'denied') {
      return res.status(400).json({ error: 'access_denied' });
    }

    // Generate tokens
    const accessToken = jwt.sign(
      { sub: deviceAuth.userId, scope: deviceAuth.scope },
      config.jwtSecret,
      { expiresIn: '1h' }
    );

    res.json({
      access_token: accessToken,
      token_type: 'Bearer',
      expires_in: 3600,
      scope: deviceAuth.scope
    });
  }
});

Client Authentication Methods

OAuth 2.1 supports multiple client authentication methods at the token endpoint.

Client Secret Basic (HTTP Basic Auth)

typescriptfunction authenticateClientBasicAuth(authHeader: string): Client | null {
  if (!authHeader || !authHeader.startsWith('Basic ')) {
    return null;
  }

  const credentials = Buffer.from(
    authHeader.substring(6),
    'base64'
  ).toString('utf-8');

  const [clientId, clientSecret] = credentials.split(':');

  return clientStore.findByCredentials(clientId, clientSecret);
}

// In token endpoint
app.post('/oauth/token', (req, res) => {
  const authHeader = req.headers.authorization;
  const client = authenticateClientBasicAuth(authHeader);

  if (!client) {
    return res.status(401).json({
      error: 'invalid_client',
      error_description: 'Client authentication failed'
    });
  }
  // Continue with token generation
});

Client Secret Post

typescriptfunction authenticateClientPostAuth(clientId: string, clientSecret: string): Client | null {
  return clientStore.findByCredentials(clientId, clientSecret);
}

app.post('/oauth/token', express.urlencoded({ extended: false }), (req, res) => {
  const { client_id, client_secret } = req.body;
  const client = authenticateClientPostAuth(client_id, client_secret);

  if (!client) {
    return res.status(401).json({ error: 'invalid_client' });
  }
  // Continue
});

Private Key JWT (mutual TLS)

typescriptasync function authenticateClientJwt(assertion: string): Promise<Client | null> {
  try {
    const decoded = jwt.verify(assertion, config.jwksEndpoint, {
      algorithms: ['RS256', 'ES256'],
      issuer: decoded.payload.iss,
      audience: decoded.payload.aud
    });

    const client = await clientStore.findById(decoded.payload.sub);
    if (!client) return null;

    // Validate JWT claims
    if (decoded.payload.exp < Date.now() / 1000) return null;
    if (decoded.payload.jti && await jtiStore.exists(decoded.payload.jti)) {
      return null; // Replay attack
    }

    return client;
  } catch (error) {
    return null;
  }
}

Enterprise IDP Integration

External identity provider configuration

typescriptinterface IdentityProvider {
  id: string;
  name: string;
  type: 'saml2' | 'oidc' | 'ldap';
  config: {
    clientId?: string;
    clientSecret?: string;
    discoveryUrl?: string;
    entityId?: string;
    metadataUrl?: string;
    attributeMapping: Record<string, string>;
  };
}

// OIDC IDP integration
class OIDCIdentityProvider {
  async discover(configUrl: string): Promise<OIDCConfig> {
    const response = await fetch(`${configUrl}/.well-known/openid-configuration`);
    return await response.json();
  }

  async exchangeCode(code: string, redirectUri: string): Promise<TokenResponse> {
    const response = await fetch(`${this.config.tokenEndpoint}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: code,
        redirect_uri: redirectUri,
        client_id: this.config.clientId,
        client_secret: this.config.clientSecret
      })
    });
    return await response.json();
  }

  async getUserInfo(accessToken: string): Promise<UserProfile> {
    const response = await fetch(`${this.config.userinfoEndpoint}`, {
      headers: { 'Authorization': `Bearer ${accessToken}` }
    });
    return await response.json();
  }
}

// SAML2 IDP integration
class SAML2IdentityProvider {
  async getAuthRequest(relyingPartyId: string): Promise<string> {
    const authRequest = this.samlLib.createAuthRequest({
      ssoUrl: this.config.ssoUrl,
      entityId: relyingPartyId,
      idpEntityId: this.config.entityId
    });
    return authRequest;
  }

  async validateResponse(samlResponse: string): Promise<SAMLAttributes> {
    const profile = await this.samlLib.validateResponse(
      samlResponse,
      this.config.metadataUrl
    );
    return profile.attributes;
  }
}

Brokering: our authorization server as broker

typescript// Broker endpoint that redirects to external IDP
app.get('/oauth/authorize', async (req, res) => {
  const { idp_hint, client_id, redirect_uri, state } = req.query;

  const idp = await idpStore.findById(idp_hint as string);
  if (!idp) {
    return res.status(400).json({ error: 'invalid_idp' });
  }

  // Generate state for our authorization server
  const brokerState = generateSecureRandom();
  await brokerStateStore.save(brokerState, {
    clientId: client_id,
    redirectUri: redirect_uri,
    state: state,
    idpId: idp.id
  });

  // Redirect to external IDP
  if (idp.type === 'oidc') {
    const oidcIdp = new OIDCIdentityProvider(idp);
    const authUrl = await oidcIdp.buildAuthUrl(brokerState);
    return res.redirect(authUrl);
  } else if (idp.type === 'saml2') {
    const samlIdp = new SAML2IdentityProvider(idp);
    const authRequest = await samlIdp.getAuthRequest(client_id);
    return res.redirect(authRequest);
  }
});

// Callback from external IDP
app.get('/oauth/callback', async (req, res) => {
  const { code, state: brokerState } = req.query;

  const stateData = await brokerStateStore.findAndDelete(brokerState);
  if (!stateData) {
    return res.status(400).json({ error: 'invalid_state' });
  }

  const idp = await idpStore.findById(stateData.idpId);

  // Exchange code with external IDP
  let externalUserProfile;
  if (idp.type === 'oidc') {
    const oidcIdp = new OIDCIdentityProvider(idp);
    const tokens = await oidcIdp.exchangeCode(code, stateData.redirectUri);
    externalUserProfile = await oidcIdp.getUserInfo(tokens.access_token);
  }

  // Map attributes to local user
  const localUser = await userStore.findOrCreateByExternalId(
    idp.id,
    externalUserProfile.sub,
    {
      email: externalUserProfile.email,
      name: externalUserProfile.name
    }
  );

  // Generate local authorization code
  const authCode = generateSecureRandom();
  await authCodeStore.save(authCode, {
    clientId: stateData.clientId,
    userId: localUser.id,
    scope: 'openid profile email',
    expiresAt: new Date(Date.now() + 10 * 60 * 1000)
  });

  // Redirect to original callback
  const callbackUrl = new URL(stateData.redirectUri);
  callbackUrl.searchParams.set('code', authCode);
  callbackUrl.searchParams.set('state', stateData.state);
  return res.redirect(callbackUrl.toString());
});

Token Introspection and Revocation

Token introspection endpoint

typescriptapp.post('/oauth/introspect', authenticateClient, async (req, res) => {
  const { token, token_type_hint } = req.body;

  // Check if access token
  let tokenInfo;
  try {
    const decoded = jwt.verify(token, config.jwtSecret);
    tokenInfo = {
      active: true,
      sub: decoded.sub,
      scope: decoded.scope,
      client_id: decoded.clientId,
      exp: decoded.exp,
      iat: decoded.iat
    };
  } catch (error) {
    tokenInfo = { active: false };
  }

  res.json(tokenInfo);
});

Token revocation endpoint

typescriptapp.post('/oauth/revoke', authenticateClient, async (req, res) => {
  const { token, token_type_hint } = req.body;

  // Revoke refresh token
  await refreshTokenStore.revokeByToken(token);

  // Add access token to blacklist (with TTL)
  try {
    const decoded = jwt.verify(token, config.jwtSecret);
    await tokenBlacklist.add(token, decoded.exp);
  } catch (error) {
    // Token already invalid
  }

  res.status(200).end();
});

Security Hardening Checklist

Mandatory security practices

yamloauth_21_security_checklist:
  transport_security:
    - Enforce HTTPS (no HTTP endpoints)
    - HSTS headers configured
    - TLS 1.3 minimum
    - Certificate pinning for mobile apps

  token_security:
    - PKCE mandatory for all clients
    - Refresh token rotation implemented
    - Short access token expiration (5-60 minutes)
    - Long refresh token expiration (30-90 days)
    - Token introspection available
    - Token revocation endpoint

  client_security:
    - Client secret hashed in storage
    - Client authentication required (except public clients)
    - Redirect URI whitelist enforced
    - Post-logout redirect URI whitelist

  session_management:
    - Absolute session timeout (24h)
    - Idle session timeout (30m)
    - Concurrent session limits
    - Session revocation on password change
    - Device fingerprinting for anomaly detection

  rate_limiting:
    - Authorization endpoint: 10 requests/min per IP
    - Token endpoint: 5 requests/min per client
    - Userinfo endpoint: 100 requests/min per access token
    - Introspection: 1000 requests/min per client

  monitoring:
    - Failed authentication attempts logged
    - Suspicious token usage patterns detected
    - Client credential compromise alerts
    - Token leakage detection (replay attacks)

Conclusion

Implementing a production-ready Authorization Server with OAuth 2.1 requires more than knowledge of basic flows — it requires correct implementation of PKCE, refresh token rotation, OIDC integration, and robust security. Enterprise patterns add complexity of integrating with external IDPs, managing multiple identity providers, and maintaining complete audit logging.

Organizations that implement OAuth 2.1 correctly significantly reduce authentication attack surfaces while providing modern user experience.


Need to implement OAuth 2.1 Authorization Server with enterprise IDP integrations? Talk to Imperialis security specialists to implement a production-ready authentication architecture with OAuth 2.1, OIDC, and corporate integrations.

Sources

Related reading