Skip to main content

Command Palette

Search for a command to run...

Build Your Own OIDC Provider (Beginner to Advanced) with Node.js, Express, PostgreSQL, and oidc-provider

Published
6 min read
Build Your Own OIDC Provider (Beginner to Advanced) with Node.js, Express, PostgreSQL, and oidc-provider

If you have ever used Google Login, GitHub Login, or "Sign in with X", you have used OpenID Connect (OIDC).

In this guide, we will build a production-style OIDC Authorization Server, step by step, using:

  • Node.js + TypeScript

  • Express

  • oidc-provider (battle-tested OIDC engine)

  • PostgreSQL + Drizzle ORM

  • Custom login + consent interactions

  • Dynamic client registration

This walkthrough is based on a real working project structure and endpoints.

What You Will Build

By the end, you will have:

  • OIDC discovery endpoint

  • Authorization endpoint

  • Token endpoint

  • UserInfo endpoint

  • JWKS endpoint

  • Introspection + Revocation endpoints

  • App-level routes for:

    • user registration and login

    • interaction UI (login + consent)

    • client registration

You also get secure defaults like bcrypt hashing, signed cookies, token TTLs, and optional persistent signing keys.

OIDC in 2 Minutes

OIDC is an identity layer on top of OAuth 2.0.

  • OAuth 2.0 answers: "Can this app access this API?"

  • OIDC answers: "Who is this user?"

Core OIDC terms:

  • Issuer: your auth server base URL (https://auth.example.com/oidc)

  • Client: app requesting login

  • Authorization Code: temporary code exchanged for tokens

  • ID Token: signed JWT containing user identity claims

  • Access Token: token used to call APIs

  • Refresh Token: a token used to obtain new access tokens

High-Level Architecture

Step 1: Bootstrapping the Project

Install dependencies:

npm install

Required runtime pieces:

  • Node.js 20+

  • PostgreSQL

  • Environment variables

Create .env from .env.example, then set:

  • DATABASE_URL

  • OIDC_ISSUER (origin only, without /oidc)

  • OIDC_COOKIE_SECRET (at least 32 chars)

In this project, the effective issuer becomes:

// src/config.ts
export function getOidcIssuer(): string {
  const base = env.OIDC_ISSUER.replace(/\/$/, "");
  return `${base}/oidc`;
}

This keeps your public origin clean while mounting OIDC at /oidc.

Step 2: Database Design for OIDC

You need tables for:

  • users

  • clients

  • grants/tokens/codes

  • sessions

  • interactions

This project uses:

  • users

  • oidc_clients

  • oidc_grants

  • oidc_sessions

  • oidc_interaction

Apply migrations:

npm run db:migrate

Why this matters:

  • OIDC flows are stateful.

  • Authorization codes, sessions, and grants must survive restarts.

  • PostgreSQL-backed adapter makes the server production-capable.

Step 3: Create the OIDC Provider

Initialize oidc-provider with:

  • custom adapter (PgAdapter)

  • issuer

  • JWKS signing keys

  • supported claims/scopes

  • endpoint routes

  • token/session TTL policy

  • custom interaction URL

The provider config defines endpoints like:

  • /auth (authorization)

  • /token

  • /userinfo

  • /jwks

  • /introspection

  • /revocation

and custom login/consent route:

  • /auth/interaction/:uid

The custom interaction URL is wired as:

// src/oidc/provider.ts
interactions: {
  url(_ctx, interaction) {
    return `/auth/interaction/${interaction.uid}`;
  },
},

Step 4: Signing Keys (JWKS) Done Right

Your server signs ID tokens with RSA keys.

This implementation does:

  • If JWKS_PRIVATE_KEY exists -> import and use it.

  • Else -> generate an ephemeral key (good for local dev only).

Why persistent keys are critical in production:

  • Token signatures must remain verifiable across restarts.

  • Rotating keys unexpectedly invalidates client verification assumptions.

Generate key (example):

openssl genrsa -out oidc-rsa.pem 2048
openssl pkcs8 -topk8 -nocrypt -in oidc-rsa.pem -out oidc-private-pkcs8.pem

Put PKCS#8 PEM into JWKS_PRIVATE_KEY.

oidc-provider Delegates user interaction to your app.

Flow in this project:

  1. Client starts OIDC auth request.

  2. Provider needs login -> redirects browser to /auth/interaction/:uid.

  3. User submits credentials.

  4. App validates user in DB (bcrypt.compare).

  5. App calls provider.interactionFinished(...) with accountId.

  6. Consent page appears.

  7. On confirm, app calls interactionFinished with consent.

This gives complete control over UX and business rules.

Step 6: User Registration + Login

Application routes:

  • POST /auth/register

  • POST /auth/login

Highlights:

  • Uses zod for request validation

  • Passwords hashed with bcrypt (BCRYPT_ROUNDS = 12)

  • Session-like account cookie created after login

Important: never store plain passwords, and always validate request payloads.

Step 7: Dynamic Client Registration

This project provides a practical API:

  • POST /clients

  • GET /clients/:clientId

Registration supports metadata like:

  • redirect_uris

  • grant_types

  • token_endpoint_auth_method

  • optional scope string

For confidential clients:

  • Generates client_secret

  • Hashes secret before persistence

  • Returns plain secret only once at creation

For public clients:

  • token_endpoint_auth_method = "none"

  • No client secret issued

This mirrors real-world onboarding patterns.

Step 8: Adapter Design (Advanced Core)

oidc-provider expects an adapter contract (upsert, find, consume, destroy, etc.).

PgAdapter maps OIDC models to PostgreSQL rows:

  • Client -> oidc_grants with type Client

  • Session -> oidc_sessions

  • Interaction -> oidc_interaction

  • Other artifacts -> oidc_grants by type

Advanced touches in this implementation:

  • automatic expiration checks and cleanup in find

  • consumption timestamps in consume

  • grant-wide revocation with revokeByGrantId

  • internal __secretHash handling for secure client secret comparison

Step 9: Security Hardening Checklist

Already present:

  • helmet() for security headers

  • CORS allowlist via ALLOWED_ORIGINS

  • signed HTTP-only cookies

  • sameSite + secure toggles based on environment

  • rate limiter on token endpoint

  • bcrypt hashing for user and client secrets

Before production launch, also ensure:

  • TLS termination and HTTPS-only issuer

  • strong secrets in env

  • no .env/private key commits

  • monitoring + audit logs

  • backup/restore strategy for Postgres

Step 10: Run and Verify

Start server:

npm run dev

Check discovery:

curl http://localhost:3000/oidc/.well-known/openid-configuration

Register user (example):

curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"password123","name":"Alice"}'

Register client (example):

curl -X POST http://localhost:3000/clients \
  -H "Content-Type: application/json" \
  -d '{
    "client_name":"demo-app",
    "redirect_uris":["http://localhost:5173/callback"],
    "grant_types":["authorization_code","refresh_token"],
    "token_endpoint_auth_method":"client_secret_basic",
    "scope":"openid profile email"
  }'

Full Authorization Code Flow


System Architecture Diagram

Authorization Code Flow Sequence Diagram

Security Hardening Diagram

Common Pitfalls (Read Before Shipping)

  • Using ephemeral JWKS key in production (breaks trust continuity).

  • Storing client secrets in plain text.

  • Overly broad CORS (*) with credentials.

  • Not validating redirect URIs strictly.

  • Missing token endpoint rate limit.

  • Not planning refresh token rotation/revocation strategy.

Advanced Improvements You Can Add Next

  • PKCE enforcement for public clients

  • Multi-factor authentication in interactions

  • Fine-grained consent storage and per-scope approvals

  • Admin panel for client lifecycle management

  • Audit log table for auth events

  • Key rotation with multiple active JWKs and kid

  • Federation / social login upstream identity providers

Final Thoughts

Building your own OIDC provider is very feasible if you delegate protocol complexity to oidc-provider and focus on:

  • strong data modeling

  • secure credential handling

  • Reliable interaction UX

  • operational safeguards

This approach gives you full control over identity while staying standards-compliant and production-ready.

Thank you, keep learning!