How it works

username.md runs a standard OpenID Connect provider (Zitadel) at login.username.md. Any OIDC client works — the snippets below are just the two most common edge platforms wired up for you. Your app never sees a password; it gets a verified identity token signed by the IdP.

01
RedirectUser hits your app, has no session → you send them to /oauth/v2/authorize with a PKCE challenge.
02
AuthenticateThey sign in at login.username.md (passkey-backed) and approve.
03
ExchangeThe IdP returns a ?code; you swap it for an id_token at /oauth/v2/token.
04
Verify & gateVerify the token against the JWKS, set a session cookie, let them in.

Try it live

This is the real, embeddable badge from https://username.md/sso-badge.js. Clicking it starts a genuine Authorization Code + PKCE redirect to login.username.md and returns to /callback, which completes the token exchange in the browser.

Signs in at login.username.md · returns to /callback

Quickstart

Pick your platform. Both put SSO in front of an entire app (a gateway), verify the session on every request, and cost you four small files or fewer. Register a client for your app first (see Register your client).

middleware.ts
// middleware.ts — gate every page behind "Sign in with username".
// Runs on Vercel's Edge runtime. jose verifies the session JWT against the
// username.md IdP's JWKS. No valid session => bounce to the login route.
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify, createRemoteJWKSet } from "jose";

const ISSUER = process.env.USERNAME_ISSUER!;              // https://login.username.md
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/oauth/v2/keys`));

// Protect everything except the auth routes and static assets.
export const config = { matcher: ["/((?!api/auth|_next|favicon.ico).*)"] };

export async function middleware(req: NextRequest) {
  const token = req.cookies.get("username_session")?.value;
  if (token) {
    try {
      await jwtVerify(token, JWKS, {
        issuer: ISSUER,
        audience: process.env.USERNAME_CLIENT_ID,
      });
      return NextResponse.next();                          // valid session -> allow
    } catch { /* fall through to login */ }
  }
  const login = new URL("/api/auth/login", req.url);
  login.searchParams.set("returnTo", req.nextUrl.pathname);
  return NextResponse.redirect(login);
}
app/api/auth/login/route.ts
// app/api/auth/login/route.ts — start OIDC Authorization Code + PKCE.
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";

const b64url = (b: Buffer) => b.toString("base64url");

export async function GET(req: NextRequest) {
  const issuer = process.env.USERNAME_ISSUER!;
  const verifier = b64url(crypto.randomBytes(32));
  const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
  const state = b64url(crypto.randomBytes(16));
  const returnTo = req.nextUrl.searchParams.get("returnTo") || "/";

  const authorize = new URL(`${issuer}/oauth/v2/authorize`);
  authorize.searchParams.set("response_type", "code");
  authorize.searchParams.set("client_id", process.env.USERNAME_CLIENT_ID!);
  authorize.searchParams.set("redirect_uri", `${process.env.APP_URL}/api/auth/callback`);
  authorize.searchParams.set("scope", "openid profile email");
  authorize.searchParams.set("state", state);
  authorize.searchParams.set("code_challenge", challenge);
  authorize.searchParams.set("code_challenge_method", "S256");

  const res = NextResponse.redirect(authorize);
  const opts = { httpOnly: true, secure: true, sameSite: "lax" as const, path: "/", maxAge: 600 };
  res.cookies.set("pkce_verifier", verifier, opts);
  res.cookies.set("oauth_state", state, opts);
  res.cookies.set("return_to", returnTo, opts);
  return res;
}
app/api/auth/callback/route.ts
// app/api/auth/callback/route.ts — exchange the code, set a session cookie.
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
  const issuer = process.env.USERNAME_ISSUER!;
  const p = req.nextUrl.searchParams;
  const code = p.get("code");
  const state = p.get("state");

  if (!code || !state || state !== req.cookies.get("oauth_state")?.value) {
    return new NextResponse("Invalid auth state", { status: 400 });
  }
  const verifier = req.cookies.get("pkce_verifier")?.value ?? "";
  const returnTo = req.cookies.get("return_to")?.value || "/";

  const body = new URLSearchParams({
    grant_type: "authorization_code",
    code,
    redirect_uri: `${process.env.APP_URL}/api/auth/callback`,
    client_id: process.env.USERNAME_CLIENT_ID!,
    code_verifier: verifier,
  });
  // Public PKCE client: omit the secret. Confidential client: set it.
  if (process.env.USERNAME_CLIENT_SECRET) {
    body.set("client_secret", process.env.USERNAME_CLIENT_SECRET);
  }

  const tokenRes = await fetch(`${issuer}/oauth/v2/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body,
  });
  if (!tokenRes.ok) return new NextResponse("Token exchange failed", { status: 401 });
  const tokens = await tokenRes.json();

  const res = NextResponse.redirect(new URL(returnTo, req.url));
  res.cookies.set("username_session", tokens.id_token, {
    httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 60 * 60 * 8,
  });
  ["pkce_verifier", "oauth_state", "return_to"].forEach((c) => res.cookies.delete(c));
  return res;
}
.env.local
# .env.local  (set the same as Encrypted Environment Variables in Vercel)
USERNAME_ISSUER=https://login.username.md
USERNAME_CLIENT_ID=<your registered client_id>
# Only for a confidential (server-side) client. A public PKCE client needs none.
USERNAME_CLIENT_SECRET=<optional client secret>
APP_URL=https://your-app.vercel.app

# npm i jose
Issuer https://login.username.md · discovery at /.well-known/openid-configuration · full worked SPA exchange in /callback.html.

Just want the button?

Drop the badge on any page. It renders the badge.md-format pill and runs the PKCE redirect on click. Set data-client-id to your registered public client and data-redirect-uri to your callback.

index.html
<!-- Put the "Sign in with username" badge on your own site. -->
<!-- 1. A mount element with YOUR registered public PKCE client_id.       -->
<div data-username-sso
     data-client-id="YOUR_PUBLIC_CLIENT_ID"
     data-redirect-uri="https://yourapp.com/callback"
     data-scope="openid profile email"
     data-handle="you"></div>

<!-- 2. The badge script (no build step, no dependencies).                -->
<script src="https://username.md/sso-badge.js" defer></script>

<!-- On click it runs Authorization Code + PKCE (S256) against            -->
<!-- login.username.md and returns to your redirect_uri with ?code=...    -->
<!-- Handle the code on your server (see the Vercel / Cloudflare tabs)     -->
<!-- or in the browser (see username.md/callback.html for a worked SPA    -->
<!-- exchange you can copy).                                              -->

Register your client

You need an OIDC client on login.username.md. Two shapes, depending on where you run the code:

If your token exchange runs…Client typeAuth
Server-side (Vercel route handler, CF Worker, any backend)Web / confidentialPKCE + client_secret
Purely in the browser (SPA, the badge)User-agent / publicPKCE only (no secret)

Values you'll set

SettingValue
Issuerhttps://login.username.md
Authorize/oauth/v2/authorize
Token/oauth/v2/token
JWKS/oauth/v2/keys
UserInfo/oidc/v1/userinfo
Scopesopenid profile email (also phone address offline_access)
Redirect URIyour /callback (and /api/auth/callback for the Vercel example)
PKCES256 (required)
The username.md first-party badge uses a public client whose id is published in /sso-config.js and provisioned by terraform/zitadel-apps in username-md-infra. For your own app, create your own client so redirect URIs and secrets stay yours.