Skip to content
Back to projects
[active]6 min read

Kleis OIDC

Started April 19, 2026·Updated May 23, 2026
Next.jsTypeScriptNode.jsTailwind CSSExpressPostgreSQLPrismaTurborepoJose

Kleis OIDC is a custom OpenID Connect (OIDC) Identity Provider and Single Sign-On (SSO) engine. No third-party auth frameworks, no abstraction layers over the protocol. Every cryptographic handshake, session verification step, and token lifecycle mechanism is implemented directly. It ships with a Next.js SDK (@kleis-auth/nextjs) so client apps can integrate in under 15 lines of code.


The Challenge

Most web apps delegate identity to external providers like Auth0, Firebase, or NextAuth. That works, but it turns authentication into a black box. When something goes wrong, you cannot see why. When you need to audit the token flow, you cannot. And when you need SSO across multiple clients, the integration surface gets complicated fast: CSRF, session hijack, token replay, race conditions.

The goal was to build a fully compliant OIDC provider from the ground up:

  • OAuth 2.0 and OIDC core specs, implemented without third-party auth libraries.
  • Secure session management, refresh token rotation, and credential protection.
  • A developer portal and client SDK so other apps can integrate without reading the source.

My Role: Sole developer and architect. Backend identity server, database schema, custom SDK, developer portal. All of it.

Why build from scratch? Third-party auth providers treat identity as a black box. A custom OIDC provider means every cryptographic handshake, token lifecycle, and session verification layer is visible and auditable.


The Solution

Kleis OIDC packages a complete authentication flow inside a Turborepo monorepo. The identity server handles credentials, client registration, consents, and token signing. Client apps get a Next.js SDK that abstracts token storage, cookie encryption, and page protection.

Core Features

  • Authorization Code Flow with PKCE (S256): The standard flow for public clients (SPAs, mobile apps). PKCE prevents authorization code interception.
  • Asymmetric signing (RS256 & JWKS): JWTs are signed with a private RSA key. Clients verify with the public key, exposed at /.well-known/jwks.json.
  • SSO: Stateful cross-client sessions. Log in once, access all registered apps.
  • Refresh token rotation: Every refresh token is single-use. If a reused token appears, the system revokes everything and forces a re-login.
  • Developer tools: A Next.js SDK with context providers, hooks, and middleware. An in-app portal for client registration.

Technical Architecture

The monorepo uses pnpm workspaces and Turborepo for build optimization:

                 ┌────────────────────────────────────────────────────────┐
                 │                       KLEIS OIDC                       │
                 │                       (Monorepo)                       │
                 └───────────┬────────────────────────────────┬───────────┘
                             │                                │
                             ▼                                ▼
                 ┌──────────────────────┐        ┌────────────────────────┐
                 │      apps/idp/       │        │   packages/nextjs/     │
                 │   (Identity Server)  │        │     (Consumer SDK)     │
                 └───────────┬──────────┘        └────────────┬───────────┘
                             │                                │
                             ▼                                ▼
                 ┌──────────────────────┐        ┌────────────────────────┐
                 │      PostgreSQL      │        │    apps/demo & apps/   │
                 │     (Prisma ORM)     │        │     (Client Apps)      │
                 └──────────────────────┘        └────────────────────────┘

The Identity Server (apps/idp/) manages credentials, developer clients, active consents, and cryptographic configurations. The Next.js SDK (packages/nextjs/) handles token storage, cookie encryption, network handshakes, and client-side page protection.


Engineering Deep Dives

1. Authorization Code Flow with PKCE (RFC 7636)

PKCE protects public clients from authorization code interception. The client generates a random code_verifier, hashes it with SHA-256 to produce a code_challenge, and sends the challenge in the initial authorization request. When exchanging the code for tokens, the client sends the raw verifier. The server recomputes the hash and compares.

┌──────────────┐             (1) /authorize (Challenge)           ┌──────────────┐
│  Client App  ├─────────────────────────────────────────────────►│  Kleis IdP   │
│              │◄─────────────────────────────────────────────────┤   (Server)   │
│ (User Agent) │          (2) Redirect with Auth Code             │              │
└──────┬───────┘                                                  └──────┬───────┘
       │                                                                 ▲
       │                                                                 │
       │             (3) POST /token (Code + Verifier)                   │
       └─────────────────────────────────────────────────────────────────┘

The server validates by comparing the computed hash against the stored challenge:

PKCE is mandatory for public clients. Without it, authorization codes can be intercepted in transit (e.g., via malicious redirect handlers). The code_verifier proves the client that started the flow is the same one exchanging the code.

// From src/lib/pkce.ts
import crypto from "crypto";
 
export function verifyPkce(
  codeVerifier: string,
  storedChallenge: string,
): boolean {
  const computed = crypto
    .createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");
 
  return computed === storedChallenge;
}

During POST requests to /token, the verifier is validated atomically:

const pkceValid = verifyPkce(input.code_verifier, authCode.codeChallenge);
if (!pkceValid) {
  throw new BadRequestError("PKCE verification failed", ErrorCodes.PKCE_FAILED);
}

2. Asymmetric Cryptographic Signing (RS256 & JWKS)

With symmetric signing (HS256), the same secret key is shared between the IdP and every client. If one client is compromised, an attacker can forge tokens for the entire system.

Kleis OIDC uses asymmetric signing (RS256) instead. The IdP signs JWTs with a private RSA key. Clients verify using the corresponding public key.

┌─────────────────────────────────┐
│           KLEIS IdP             │
│  (Signs JWT with Private Key)   │
└────────────────┬────────────────┘

                 ▼ JWT Token Issued
┌─────────────────────────────────┐
│           CLIENT APP            │
│  (Verifies JWT with Public Key) │
│  Exposed at /.well-known/jwks   │
└─────────────────────────────────┘

Using the jose library, the server loads PEM-formatted keys to sign OIDC tokens:

// From src/lib/jwt.ts
import { SignJWT, importPKCS8 } from "jose";
import { privateKeyPem, KEY_ID, ISSUER } from "../config/keys";
 
export async function signJwt(
  payload: Record<string, unknown>,
  expiresIn: string,
) {
  const privateKey = await importPKCS8(privateKeyPem, "RS256");
 
  return new SignJWT(payload)
    .setProtectedHeader({ alg: "RS256", kid: KEY_ID })
    .setIssuer(ISSUER)
    .setIssuedAt()
    .setExpirationTime(expiresIn)
    .sign(privateKey);
}

The public keys are served at /.well-known/jwks.json. Clients cache them for offline token validation, which removes the need for a network roundtrip on every request.

3. Refresh Token Rotation & Reuse Detection

Access tokens are short-lived. Refresh tokens let users stay logged in without re-authenticating constantly. But if a refresh token is stolen, an attacker can mint new access tokens indefinitely.

Kleis OIDC uses single-use refresh tokens. Each time one is used, it is invalidated and a new pair is issued. If the server sees a token that was already used, it assumes a breach and revokes everything.

Token reuse = breach. When a refresh token that was already used appears again, an attacker intercepted it. The only safe response is to revoke every token for that user and force a fresh login.

[ Active Token ] ──(Used once)──► [ Issue New Token ] + [ Mark Old Used ]

 (Attempted Reuse)

 [ Revoke All Tokens for User/Client ] ──► [ Force Login ]

To prevent race conditions (concurrent client-side requests triggering duplicate refreshes), this validation runs inside a Prisma database transaction:

// From src/services/token.service.ts
return prisma.$transaction(async (tx) => {
  const updated = await tx.refreshToken.updateMany({
    where: { token: input.refresh_token, usedAt: null },
    data: { usedAt: new Date() },
  });
 
  // If count is 0, the token was already marked used!
  if (updated.count === 0) {
    log.error(
      {
        clientId: input.client_id,
        userId: stored.userId,
        security: true,
      },
      "Refresh token reuse detected: revoking all tokens",
    );
 
    // Immediate mitigation: revoke everything
    await authService.revokeTokensForLogout(stored.userId, input.client_id);
    throw new BadRequestError(
      "Refresh token reuse detected: all tokens revoked",
      ErrorCodes.TOKEN_REUSE_DETECTED,
    );
  }
 
  // Create and issue new pair...
});

Technical Challenges & Trade-offs

1. From-Scratch Protocol Compliance vs. Off-the-Shelf Packages

Frameworks like oidc-provider abstract the specifications into a black box. Writing standard-compliant OAuth 2.0 and OIDC endpoints meant building custom parsing for application/x-www-form-urlencoded payloads, handling strict redirect URI wildcard matching, and structuring error payloads per RFC 6749 with proper error and error_description fields.

Trade-off: development time vs. visibility. Building from scratch took longer, but every token signing decision, session state transition, and security check is directly observable and modifiable.

  • Decision: Building from scratch gave direct control over the data layers and security states. The trade-off was more initial work, but the result is a lightweight codebase with full visibility into the signing and validation pipeline.

2. State Management: Stateful SSO Sessions vs. Stateless REST APIs

Modern APIs tend toward statelessness. But SSO requires a central session store to coordinate logouts and revocations across clients.

  • Decision: Stateful sessions inside apps/idp, using Express Sessions backed by PostgreSQL. The initial /authorize redirect hits the database, but this enables multi-app logout and immediate session revocation.

3. Cryptographic Performance: RS256 vs. HS256

HS256 is fast and simple to set up. But sharing the secret key with multiple clients creates a single point of failure.

  • Decision: RS256 asymmetric signing. Slightly more CPU for verification, but clients only get the public key. A compromised client cannot forge tokens.

Results & Impact

From zero to production-grade. Building a secure identity provider from scratch is not just an exercise. It produces a lightweight, auditable system that gives you more control than third-party solutions.

  • End-to-end compliance: OAuth 2.0 and OIDC Core 1.0, implemented without abstraction layers.
  • Reduced attack surface: PKCE, RS256 asymmetric signatures, and transactional refresh token rotation protect against code interception and token replay.
  • Developer integration: The @kleis-auth/nextjs SDK reduces authentication setup to under 15 lines of code in React/Next.js projects.
  • Scalable architecture: Turborepo monorepo separating the identity server, developer portal, and client SDK.