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_URLOIDC_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:
usersoidc_clientsoidc_grantsoidc_sessionsoidc_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_KEYexists -> 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.
Step 5: Build Custom Login + Consent Interactions
oidc-provider Delegates user interaction to your app.
Flow in this project:
Client starts OIDC auth request.
Provider needs login -> redirects browser to
/auth/interaction/:uid.User submits credentials.
App validates user in DB (
bcrypt.compare).App calls
provider.interactionFinished(...)withaccountId.Consent page appears.
On confirm, app calls
interactionFinishedwithconsent.
This gives complete control over UX and business rules.
Step 6: User Registration + Login
Application routes:
POST /auth/registerPOST /auth/login
Highlights:
Uses
zodfor request validationPasswords 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 /clientsGET /clients/:clientId
Registration supports metadata like:
redirect_urisgrant_typestoken_endpoint_auth_methodoptional scope string
For confidential clients:
Generates
client_secretHashes 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_grantswith typeClientSession ->
oidc_sessionsInteraction ->
oidc_interactionOther artifacts ->
oidc_grantsbytype
Advanced touches in this implementation:
automatic expiration checks and cleanup in
findconsumption timestamps in
consumegrant-wide revocation with
revokeByGrantIdinternal
__secretHashhandling for secure client secret comparison
Step 9: Security Hardening Checklist
Already present:
helmet()for security headersCORS allowlist via
ALLOWED_ORIGINSsigned HTTP-only cookies
sameSite+securetoggles based on environmentrate 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 commitsmonitoring + 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
kidFederation / 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!



