Implementing an OpenID Connect Server from First Principles
Authentication libraries hide an enormous amount of complexity. That's exactly what they're supposed to do.
But after integrating OAuth and OpenID Connect into multiple applications, I realized I understood how to use them—not how they actually worked. So I decided to build an OpenID Connect provider from scratch.
Not because existing libraries weren't good enough, but because I wanted to understand every moving part.
Why Build It Yourself?
Every OAuth tutorial eventually reaches the point where it says:
"Redirect the user to the authorization endpoint."
Then everything magically works.
The authorization code appears.
The token endpoint returns an access token.
An ID token gets generated.
But where did all of those come from?
Implementing the protocol yourself forces you to answer questions that abstractions normally hide.
- How is an authorization code generated?
- What prevents replay attacks?
- How do refresh tokens work?
- What exactly goes inside an ID token?
- How does a client know the issuer can be trusted?
Those questions become implementation details instead of documentation.
Understanding the Roles
OpenID Connect extends OAuth 2.1 by adding identity.
There are four primary actors:
- Resource Owner — the user.
- Client — the application requesting authentication.
- Authorization Server — issues tokens.
- Resource Server — validates access tokens and serves protected resources.
The Authorization Server became the focus of this project.
Client
│
│ Authorization Request
▼
Authorization Server
│
│ Authorization Code
▼
Client
│
│ Token Request
▼
Authorization Server
│
├── Access Token
├── Refresh Token
└── ID Token
Every request is simply moving credentials through a well-defined state machine.
The Authorization Code Flow
The Authorization Code Flow looks deceptively simple.
- User visits the client application.
- Client redirects the user to the authorization server.
- User authenticates.
- Authorization server issues a short-lived authorization code.
- Client exchanges the code for tokens.
- Client authenticates future requests using the access token.
The difficult part isn't the redirects.
It's maintaining trust between every step.
Each authorization code must:
- expire quickly,
- only be used once,
- belong to one client,
- belong to one redirect URI,
- and be impossible to guess.
A random UUID isn't enough unless it's tied back to server-side state.
Designing the Data Model
The implementation ended up being surprisingly small.
User
Client
AuthorizationCode
AccessToken
RefreshToken
Session
Every token references both a client and a user.
Authorization codes additionally store:
- PKCE challenge
- redirect URI
- scopes
- expiration
- consumed state
This makes replay attacks trivial to detect.
Signing ID Tokens
An ID Token is just a JWT.
The important part isn't creating the JWT.
It's creating one that other applications can verify.
The server generates a signing key pair.
Private Key
│
▼
Sign JWT
│
▼
ID Token
│
▼
Client verifies using Public Key
The public key is exposed through the JWKS endpoint.
/.well-known/jwks.json
Applications never need access to the private key.
Only the authorization server signs tokens.
Everyone else verifies them.
Supporting PKCE
Modern OAuth should always support PKCE.
Instead of trusting only the authorization code, the client also proves possession of a secret generated before the login flow begins.
Code Verifier
│
SHA-256 │
▼
Code Challenge
The authorization server stores the challenge.
When the client exchanges the authorization code, it sends the original verifier.
If the hash doesn't match, the request is rejected.
Without PKCE, authorization code interception becomes significantly easier.
Discovery Endpoints
One of my favorite parts of OpenID Connect is automatic discovery.
Instead of manually configuring endpoints, clients simply fetch:
/.well-known/openid-configuration
Which advertises everything:
- issuer
- authorization endpoint
- token endpoint
- userinfo endpoint
- jwks URI
- supported scopes
- supported signing algorithms
Adding discovery made integrating clients dramatically simpler.
Refresh Tokens
Access tokens should be short-lived.
Refresh tokens allow clients to obtain new access tokens without asking users to log in again.
The implementation rotates refresh tokens every time they're used.
Refresh Token A
│
▼
Access Token B
Refresh Token B
If an old refresh token is ever reused, it's immediately invalidated.
This helps mitigate token theft.
What Was Surprisingly Difficult
The protocol itself wasn't the hardest part.
The edge cases were.
- Expired authorization codes.
- Reused authorization codes.
- Invalid redirect URIs.
- Clock drift during JWT validation.
- Key rotation.
- Logout.
- Revoking refresh tokens.
- Multiple concurrent sessions.
Every one of these required careful handling.
Authentication is mostly edge cases.
Lessons Learned
Building an OpenID Connect provider changed how I think about authentication.
OAuth isn't complicated because of cryptography.
It's complicated because it's coordinating trust between multiple independent systems.
The specifications are intentionally strict because small implementation mistakes become security vulnerabilities.
I still use existing libraries in production.
But now I understand what they're doing—and, more importantly, why they're doing it.
That understanding is far more valuable than the code I wrote.