All posts
28 Aug 2026

Building an OAuth2 and OpenID Connect Provider from Scratch in Node.js

{"title": "Building an OAuth2 and OpenID Connect Provider from Scratch in Node.

{“title”: “Building an OAuth2 and OpenID Connect Provider from Scratch in Node.js”, “summary”: “Demystify OAuth2 and OpenID Connect specifications by implementing authorization code flows, token generation, and user info endpoints manually in Node.js.”, “tags”: [“Node.js”, “Security”, “Authentication”, “API Design”, “Backend”], “body”: “# Building an OAuth2 and OpenID Connect Provider from Scratch in Node.js

Modern web applications rely heavily on centralized authentication systems. Whether you are using Auth0, Okta, Keycloak, or Google Identity, protocols like OAuth 2.0 and OpenID Connect (OIDC) power the way users log in and grant application access.

While using pre-built identity providers is standard practice in production, the specifications themselves can feel like black boxes. Terms like authorization_code, id_token, JWKS, and PKCE often get thrown around, but how do they actually work under the hood?

In this post, we are going to pull back the curtain. We will build a lightweight, fully compliant OAuth2 and OIDC provider from scratch in Node.js using Express and jsonwebtoken, implementing the Authorization Code Flow with PKCE, an Authorization Endpoint, a Token Endpoint, and a UserInfo Endpoint.


1. The Architecture of Auth2 and OIDC

Before writing code, let’s ground ourselves in the core concepts.

  • OAuth 2.0 is an authorization framework. It allows a client application to access resources on behalf of a resource owner (the user). It issues Access Tokens.
  • OpenID Connect (OIDC) is an authentication layer built on top of OAuth 2.0. It standardizes identity by introducing ID Tokens (JSON Web Tokens containing user profile data) and the /userinfo endpoint.

The Authorization Code Flow (with PKCE)

Our provider will support the Authorization Code Flow with Proof Key for Code Exchange (PKCE), which is the gold standard for secure authentication in Single Page Apps (SPAs), Mobile Apps, and secure Server-Side Apps alike.

  1. Authorization Request: The client redirects the user to our /authorize endpoint with a code challenge, client ID, and redirect URI.
  2. User Authentication: Our provider authenticates the user (via a login form or session) and asks them to consent.
  3. Authorization Code: Upon consent, our provider redirects the user back to the client with a short-lived authorization_code.
  4. Token Exchange: The client sends the authorization_code and the PKCE code_verifier directly to our /token endpoint.
  5. Token Issuance: We validate the code and verifier, then return an access_token and an id_Token.

2. Setting Up the Project

Let’s initialize our Node.js environment. Create a new directory and install our core dependencies: Express for routing, jsonwebtoken for signing tokens, and uuid for generating unique identifiers.

bash
mkdir oidc-provider-scratch
cd oidc-provider-scratch
npm init -y
npm install express jsonwebtoken uuid

Create a file named server.js. We will build our entire in-memory identity provider inside this single file for clarity.

const express = require('express');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

const PORT = 3000;

// Mock Database
const USERS = [
  { sub: 'user-123', email: 'alice@example.com', name: 'Alice Smith', password: 'password123' }
];

const CLIENTS = [
  {
    client_id: 'my-spa-client',
    client_secret: 'super-secret-key', // Optional for public clients using PKCE
    redirect_uris: ['http://localhost:8080/callback']
  }
];

// In-memory stores for runtime state
const authorizationCodes = new Map(); // code -> { client_id, redirect_uri, sub, code_challenge }
const refreshTokens = new Map();     // refresh_token -> sub

// Cryptographic Keys for Signing JWTs (In production, use private/public RSA keys)
const JWT_SECRET = 'YOUR_SUPER_SECRET_HMAC_KEY';
const ISSUER = `http://localhost:${PORT}`;

3. Implementing the Authorization Endpoint

The authorization endpoint is where the user interacts with the provider. When a client application redirects a user here, we verify the parameters, render a simple login screen, and capture user consent.

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

  // 1. Validate Client
  const client = CLIENTS.find(c => c.client_id === client_id);
  if (!client || !client.redirect_uris.includes(redirect_uri)) {
    return res.status(400).send('Invalid client_id or redirect_uri');
  }

  if (response_type !== 'code') {
    return res.status(400).send('Unsupported response_type. Only "code" is supported.');
  }

  // 2. Render a simple login form (In a real app, check user session first)
  res.send(`
    <html>
      <head><title>Sign In</title></head>
      <body style="font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh;">
        <form method="POST" action="/authorize" style="border: 1px solid #ccc; padding: 2rem; border-radius: 8px;">
          <h2>Sign In to Identity Provider</h2>
          <input type="hidden" name="client_id" value="${client_id}" />
          <input type="hidden" name="redirect_uri" value="${redirect_uri}" />
          <input type="hidden" name="code_challenge" value="${code_challenge || ''}" />
          <input type="hidden" name="code_challenge_method" value="${code_challenge_method || ''}" />
          <input type="hidden" name="state" value="${state || ''}" />
          
          <div style="margin-bottom: 1rem;">
            <label>Email:</label><br/>
            <input type="email" name="email" value="alice@example.com" style="width: 100%; padding: 0.5rem;" />
          </div>
          <div style="margin-bottom: 1rem;">
            <label>Password:</label><br/>
            <input type="password" name="password" value="password123" style="width: 100%; padding: 0.5rem;" />
          </div>
          <button type="submit" style="width: 100%; padding: 0.75rem; background: #007bff; color: white; border: none; border-radius: 4px;">Authorize</button>
        </form>
      </body>
    </html>
  `);
});

When the user submits their credentials, our provider validates them, generates a single-use authorization code, and redirects the user back to the client application.

app.post('/authorize', (req, res) => {
  const { email, password, client_id, redirect_uri, code_challenge, code_challenge_method, state } = req.body;

  // 1. Authenticate User
  const user = USERS.find(u => u.email === email && u.password === password);
  if (!user) {
    return res.status(401).send('Invalid username or password');
  }

  // 2. Generate Authorization Code (expires in 60 seconds)
  const authCode = uuidv4();
  authorizationCodes.set(authCode, {
    client_id,
    redirect_uri,
    sub: user.sub,
    code_challenge,
    code_challenge_method,
    expiresAt: Date.now() + 60000
  });

  // 3. Redirect back to client with code and state
  const redirectUrl = new URL(redirect_uri);
  redirectUrl.searchParams.append('code', authCode);
  if (state) redirectUrl.searchParams.append('state', state);

  res.redirect(redirectUrl.toString());
});

4. Implementing PKCE Verification

Before writing the token endpoint, let’s write a quick helper function to verify PKCE code challenges. PKCE protects public clients from authorization code interception attacks by requiring the client to send a code_verifier that hashes to the code_challenge sent earlier.

function verifyPKCE(verifier, challenge, method) {
  if (!challenge) return true; // If no challenge was sent, bypass (though not recommended for public clients)
  if (method === 'S256') {
    const computedHash = crypto
      .createHash('sha256')
      .update(verifier)
      .digest('base62') // standard base64url encoding
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
    return computedHash === challenge;
  }
  // Fallback for 'plain'
  return verifier === challenge;
}

5. Implementing the Token Endpoint

The token endpoint handles the backend exchange of the authorization_code for tokens. This is where OIDC shines by issuing an id_token alongside the OAuth2 access_token.

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

  if (grant_type !== 'authorization_code') {
    return res.status(400).json({ error: 'unsupported_grant_type' });
  }

  // 1. Validate Authorization Code
  const authData = authorizationCodes.get(code);
  if (!authData || authData.expiresAt < Date.now()) {
    authorizationCodes.delete(code);
    return res.status(400).json({ error: 'invalid_grant', error_description: 'Code is invalid or expired' });
  }

  if (authData.client_id !== client_id || authData.redirect_uri !== redirect_uri) {
    return res.status(400).json({ error: 'invalid_grant', error_description: 'Client or redirect URI mismatch' });
  }

  // 2. Verify PKCE Code Verifier
  if (authData.code_challenge) {
    const isValidPKCE = verifyPKCE(code_verifier, authData.code_challenge, authData.code_challenge_method);
    if (!isValidPKCE) {
      return res.status(400).json({ error: 'invalid_grant', error_description: 'Failed PKCE verification' });
    }
  }

  // Consume code (one-time use)
  authorizationCodes.delete(code);

  // 3. Find User
  const user = USERS.find(u => u.sub === authData.sub);

  // 4. Issue Access Token (OAuth2)
  const accessToken = jwt.sign(
    { sub: user.sub, client_id, scope: 'openid profile email' },
    JWT_SECRET,
    { expiresIn: '1h' }
  );

  // 5. Issue ID Token (OIDC) - Contains assertions about the authentication of a user
  const idToken = jwt.sign(
    {
      iss: ISSUER,
      sub: user.sub,
      aud: client_id,
      exp: Math.floor(Date.now() / 1000) + 3600,
      iat: Math.floor(Date.now() / 1000),
      auth_time: Math.floor(Date.now() / 1000),
      email: user.email,
      name: user.name
    },
    JWT_SECRET
  );

  // Return tokens to client
  res.json({
    access_token: accessToken,
    token_type: 'Bearer',
    expires_in: 3600,
    id_token: idToken
  });
});

6. Implementing the UserInfo Endpoint

In OpenID Connect, the client can use the Access Token to query the /userinfo endpoint to fetch standard claims about the currently authenticated user.

app.get('/userinfo', (req, res) => {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'invalid_token', error_description: 'Missing or malformed token' });
  }

  const token = authHeader.split(' ')[1];

  try {
    // Verify Access Token
    const payload = jwt.verify(token, JWT_SECRET);
    const user = USERS.find(u => u.sub === payload.sub);

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

    // Return standard OIDC claims
    res.json({
      sub: user.sub,
      email: user.email,
      email_verified: true,
      name: user.name
    });
  } catch (err) {
    return res.status(401).json({ error: 'invalid_token', error_description: err.message });
  }
});

7. Adding OIDC Discovery Metadata (.well-known)

OIDC providers publish a configuration document so client libraries can auto-configure endpoints. Let’s add the standard OpenID configuration discovery route.

app.get('/.well-known/openid-configuration', (req, res) => {
  res.json({
    issuer: ISSUER,
    authorization_endpoint: `${ISSUER}/authorize`,
    token_endpoint: `${ISSUER}/token`,
    userinfo_endpoint: `${ISSUER}/userinfo`,
    response_types_supported: ['code'],
    subject_types_supported: ['public'],
    id_token_signing_alg_values_supported: ['HS256'],
    code_challenge_methods_supported: ['S256', 'plain']
  });
});

app.listen(PORT, () => {
  console.log(`Identity Provider running at ${ISSUER}`);
});

8. Testing Your Provider

Start your server:

node server.js

You can now point any OIDC-compliant client library (like oidc-client-js or Passport.js strategies) or construct manual browser requests to http://localhost:3000/.well-known/openid-configuration to watch your custom identity provider spring to life.

Summary of What We Built:

  • The /authorize endpoint handles user authentication and consent, spitting out a temporary authorization code.
  • PKCE security logic ensures that even public clients are protected against code interception attacks.
  • The /token endpoint safely exchanges codes for cryptographic Access Tokens and ID Tokens.
  • The /userinfo endpoint provides normalized claims using the Bearer token standard.

Building this from scratch strips away the complexity of enterprise tools and reveals how simple the underlying RFC specifications for OAuth2 and OIDC really are.”}

More posts