# 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

![](https://cdn.hashnode.com/uploads/covers/69522d7bda0de13bd90a31be/951e5e67-86e7-4e1f-a0a3-652e0e317596.png align="center")

## Step 1: Bootstrapping the Project

Install dependencies:

```bash
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:

```ts
// 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:

```bash
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:

```ts
// 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):

```bash
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`.

## Step 5: Build Custom Login + Consent Interactions

`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:

```bash
npm run dev
```

Check discovery:

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

Register user (example):

```bash
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):

```bash
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

![](https://cdn.hashnode.com/uploads/covers/69522d7bda0de13bd90a31be/39572634-eca7-4ab8-9e08-8984b433e47b.png align="center")

* * *

### System Architecture Diagram

![](https://cdn.hashnode.com/uploads/covers/69522d7bda0de13bd90a31be/32986de9-069d-45f8-9ec5-66501512fa8d.png align="center")

### Authorization Code Flow Sequence Diagram

![](https://cdn.hashnode.com/uploads/covers/69522d7bda0de13bd90a31be/a5baf91e-9583-43df-95f8-46b84f0c70e7.png align="center")

### Security Hardening Diagram

![](https://cdn.hashnode.com/uploads/covers/69522d7bda0de13bd90a31be/67c88e93-faa5-4b96-aee4-c64cd2a51999.png align="center")

## 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!*
