OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0 that enables clients to verify the identity of end users and obtain basic profile information. While most applications consume OIDC as a relying party (logging in with Google, GitHub, or Okta), building your own OIDC provider gives you full control over the authentication flow, token formats, session management, and user data — essential when your platform needs to issue identity tokens that downstream services and third-party integrations can verify independently.
A production SaaS platform might implement a custom OIDC provider with ES256 (ECDSA with P-256 and SHA-256) token signing, PKCE (Proof Key for Code Exchange) enforcement for all public clients, the authorization code flow for server-side applications, and JWT-based session management backed by PostgreSQL. Such a provider issues ID tokens and access tokens that are consumed by the platform dashboard, CLI tools, and external integrations.
Building a spec-compliant OIDC provider requires implementing several interrelated endpoints — the discovery document, authorization endpoint, token endpoint, userinfo endpoint, and JWKS endpoint — along with robust session management, CSRF protection, and token lifecycle handling. Getting any of these wrong introduces security vulnerabilities, so the implementation must be precise and well-tested.
The authorization code flow is the most secure OAuth 2.0 grant type for user-facing applications. The client redirects the user to the authorization endpoint, where they authenticate and consent. The provider issues a short-lived authorization code, which the client exchanges for tokens at the token endpoint. PKCE adds a code_verifier/code_challenge pair that prevents authorization code interception attacks — even if an attacker captures the code, they cannot exchange it without the original verifier. PKCE is mandatory for public clients (SPAs, mobile apps) and recommended for all clients.
ID tokens and access tokens are JWTs signed with ES256 (ECDSA using the P-256 curve and SHA-256 hash). ES256 produces compact signatures (64 bytes vs. 256 bytes for RS256) and uses smaller keys (256-bit vs. 2048-bit), making tokens smaller and key rotation faster. The provider publishes its public keys at the JWKS endpoint so that any relying party can verify token signatures without contacting the provider, enabling fully decentralized token validation.
The /.well-known/openid-configuration endpoint returns a JSON document describing the provider’s capabilities: issuer URL, authorization and token endpoint URLs, supported grant types, signing algorithms, and scopes. Relying parties use this document to auto-configure themselves, eliminating manual endpoint configuration. A spec-compliant discovery document is essential for interoperability with standard OIDC client libraries.
Sessions are represented as JWTs stored in HTTP-only, secure, SameSite cookies. The session JWT contains the user ID, session ID, and expiration time, signed with the same ES256 key. PostgreSQL stores session metadata (creation time, last activity, IP address, user agent) for audit and revocation. When a session JWT is presented, the provider verifies the signature, checks expiration, and validates that the session has not been revoked in the database.
The /oauth/jwks endpoint serves the provider’s public keys in JWK Set format. For key rotation, the provider generates a new key pair, adds the new public key to the JWKS, starts signing new tokens with the new key, and removes the old public key only after all tokens signed with it have expired. The kid (Key ID) header in each JWT identifies which key was used to sign it, allowing relying parties to select the correct verification key from the JWKS.
The OIDC provider initializes by loading or generating ES256 key pairs and configuring the discovery document metadata.
package oidc
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"fmt"
"math/big"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type Provider struct {
issuer string
signingKey *ecdsa.PrivateKey
keyID string
db *SessionStore
}
type JWK struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
Kid string `json:"kid"`
Use string `json:"use"`
Alg string `json:"alg"`
}
func NewProvider(issuer string, db *SessionStore) (*Provider, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generating ES256 key: %w", err)
}
return &Provider{
issuer: issuer,
signingKey: key,
keyID: uuid.New().String()[:8],
db: db,
}, nil
}
func (p *Provider) JWKS() map[string]interface{} {
pub := p.signingKey.Public().(*ecdsa.PublicKey)
return map[string]interface{}{
"keys": []JWK{{
Kty: "EC",
Crv: "P-256",
X: base64.RawURLEncoding.EncodeToString(pub.X.Bytes()),
Y: base64.RawURLEncoding.EncodeToString(pub.Y.Bytes()),
Kid: p.keyID,
Use: "sig",
Alg: "ES256",
}},
}
}
The authorization endpoint validates the client, stores the PKCE challenge, authenticates the user, and issues an authorization code.
package oidc
import (
"crypto/sha256"
"encoding/base64"
"net/http"
"time"
"github.com/google/uuid"
)
type AuthorizationCode struct {
Code string
ClientID string
RedirectURI string
UserID string
Scope string
CodeChallenge string
CodeChallengeMethod string
ExpiresAt time.Time
Nonce string
}
func (p *Provider) HandleAuthorize(w http.ResponseWriter, r *http.Request) {
clientID := r.URL.Query().Get("client_id")
redirectURI := r.URL.Query().Get("redirect_uri")
scope := r.URL.Query().Get("scope")
state := r.URL.Query().Get("state")
nonce := r.URL.Query().Get("nonce")
codeChallenge := r.URL.Query().Get("code_challenge")
challengeMethod := r.URL.Query().Get("code_challenge_method")
client, err := p.db.GetClient(r.Context(), clientID)
if err != nil || !client.ValidateRedirectURI(redirectURI) {
http.Error(w, "invalid_client", http.StatusBadRequest)
return
}
if client.IsPublic && codeChallenge == "" {
http.Error(w, "PKCE required for public clients", http.StatusBadRequest)
return
}
if challengeMethod != "" && challengeMethod != "S256" {
http.Error(w, "only S256 challenge method supported", http.StatusBadRequest)
return
}
userID, err := p.authenticateUser(w, r)
if err != nil {
return
}
code := &AuthorizationCode{
Code: uuid.New().String(),
ClientID: clientID,
RedirectURI: redirectURI,
UserID: userID,
Scope: scope,
CodeChallenge: codeChallenge,
CodeChallengeMethod: challengeMethod,
ExpiresAt: time.Now().Add(10 * time.Minute),
Nonce: nonce,
}
if err := p.db.StoreAuthCode(r.Context(), code); err != nil {
http.Error(w, "server_error", http.StatusInternalServerError)
return
}
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code.Code, state)
http.Redirect(w, r, redirectURL, http.StatusFound)
}
func verifyPKCE(challenge, verifier, method string) bool {
if method == "S256" {
h := sha256.Sum256([]byte(verifier))
computed := base64.RawURLEncoding.EncodeToString(h[:])
return computed == challenge
}
return challenge == verifier
}
The token endpoint exchanges authorization codes for ID tokens and access tokens, verifying the PKCE code verifier in the process.
package oidc
import (
"encoding/json"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
)
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token,omitempty"`
}
func (p *Provider) HandleToken(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
grantType := r.FormValue("grant_type")
if grantType != "authorization_code" {
http.Error(w, "unsupported_grant_type", http.StatusBadRequest)
return
}
code := r.FormValue("code")
codeVerifier := r.FormValue("code_verifier")
authCode, err := p.db.GetAndDeleteAuthCode(r.Context(), code)
if err != nil {
http.Error(w, "invalid_grant", http.StatusBadRequest)
return
}
if time.Now().After(authCode.ExpiresAt) {
http.Error(w, "invalid_grant", http.StatusBadRequest)
return
}
if authCode.CodeChallenge != "" {
if !verifyPKCE(authCode.CodeChallenge, codeVerifier, authCode.CodeChallengeMethod) {
http.Error(w, "invalid_grant", http.StatusBadRequest)
return
}
}
now := time.Now()
idToken := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": p.issuer,
"sub": authCode.UserID,
"aud": authCode.ClientID,
"exp": now.Add(1 * time.Hour).Unix(),
"iat": now.Unix(),
"nonce": authCode.Nonce,
})
idToken.Header["kid"] = p.keyID
idTokenStr, err := idToken.SignedString(p.signingKey)
if err != nil {
http.Error(w, "server_error", http.StatusInternalServerError)
return
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": p.issuer,
"sub": authCode.UserID,
"scope": authCode.Scope,
"exp": now.Add(1 * time.Hour).Unix(),
"iat": now.Unix(),
})
accessToken.Header["kid"] = p.keyID
accessTokenStr, err := accessToken.SignedString(p.signingKey)
if err != nil {
http.Error(w, "server_error", http.StatusInternalServerError)
return
}
resp := TokenResponse{
AccessToken: accessTokenStr,
TokenType: "Bearer",
ExpiresIn: 3600,
IDToken: idTokenStr,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(resp)
}
A platform OIDC provider serves as the identity layer for the entire ecosystem. The web dashboard uses the authorization code flow with PKCE to authenticate users and obtain ID tokens that display user identity. CLI tools use the device authorization flow (an extension) to authenticate headless sessions. Internal microservices validate access tokens by fetching the JWKS and verifying signatures locally, without making network calls back to the provider for each request.
Third-party integrations that connect to a service API use the client credentials flow to obtain access tokens scoped to their authorized operations. The OIDC discovery document enables these integrations to auto-configure by pointing their OIDC client library at the issuer URL, eliminating manual endpoint configuration and reducing integration friction.
For high-throughput services, local JWT validation (verify signature using cached JWKS, check expiration) is preferred over token introspection (call the provider’s introspection endpoint for each request). Local validation requires no network round-trip but cannot detect revoked tokens until they expire. A hybrid approach caches JWKS with a short TTL and uses introspection only for sensitive operations.
JWT sessions cannot be “invalidated” cryptographically — once signed, a JWT is valid until it expires. The provider maintains a session table in PostgreSQL where each session can be marked as revoked. Token validation checks this table (or a Redis cache of revoked session IDs) to enforce immediate logout. Short token lifetimes (15–60 minutes) with refresh token rotation limit the window of exposure.
ES256 keys should be rotated periodically (e.g., every 90 days). The rotation procedure publishes the new key to JWKS before using it for signing, ensuring a transition period where both old and new keys are in the JWKS. After all tokens signed with the old key have expired (max token lifetime after the switch), the old key is removed from the JWKS.
code_challenge for public clients, and strongly recommend it for confidential clients.GetAndDelete rather than Get when consuming codes.nonce parameter prevents ID token replay attacks. Clients should generate a unique nonce per authorization request and verify it appears in the returned ID token. The provider must faithfully pass the nonce through to the ID token claims.