OIDC Provider Implementation

Authentication & Authorization advanced Building with AI ~25 min read

Overview

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.

Core Concepts

Authorization Code Flow with PKCE

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.

ES256 Token Signing

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.

Discovery Document

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.

JWT Session Management

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.

JWKS Endpoint and Key Rotation

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.

How It Works

Provider Configuration and Key Management

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",
        }},
    }
}

Authorization Endpoint with PKCE

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
}

Token Endpoint

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)
}

Practical Applications

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.

Implementation Patterns

Token Introspection vs. Local Validation

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.

Session Revocation

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.

Key Rotation Schedule

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.

Common Pitfalls

Further Reading