Put Sign in with username in front of your own app. Standards
OIDC (Authorization Code + PKCE) against the live IdP at
login.username.md — copy, paste, ship.
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.
/oauth/v2/authorize with a PKCE challenge.login.username.md (passkey-backed) and approve.?code; you swap it for an id_token at /oauth/v2/token.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.
login.username.md · returns to /callbackPick 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 — 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 — 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 — 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 (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// src/index.ts — a Cloudflare Worker that gates an app behind username SSO.
// Put it on a route in front of your origin (e.g. app.example.com/*).
import { jwtVerify, createRemoteJWKSet } from "jose";
interface Env {
USERNAME_ISSUER: string; // https://login.username.md
USERNAME_CLIENT_ID: string;
USERNAME_CLIENT_SECRET?: string; // omit for a public PKCE client
}
const b64url = (buf: ArrayBuffer) =>
btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const sha256 = async (s: string) => b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)));
const rand = (n: number) => { const a = new Uint8Array(n); crypto.getRandomValues(a); return b64url(a.buffer); };
const cookie = (name: string, req: Request) => {
const m = (req.headers.get("Cookie") || "").match(new RegExp("(?:^|; )" + name + "=([^;]+)"));
return m ? decodeURIComponent(m[1]) : null;
};
const OPTS = "Path=/; HttpOnly; Secure; SameSite=Lax";
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
const issuer = env.USERNAME_ISSUER;
const jwks = createRemoteJWKSet(new URL(`${issuer}/oauth/v2/keys`));
const redirectUri = `${url.origin}/callback`;
// 1) OIDC redirect back from the IdP
if (url.pathname === "/callback") {
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code || !state || state !== cookie("oauth_state", req)) return new Response("bad state", { status: 400 });
const body = new URLSearchParams({
grant_type: "authorization_code", code, redirect_uri: redirectUri,
client_id: env.USERNAME_CLIENT_ID, code_verifier: cookie("pkce_verifier", req) || "",
});
if (env.USERNAME_CLIENT_SECRET) body.set("client_secret", env.USERNAME_CLIENT_SECRET);
const tr = await fetch(`${issuer}/oauth/v2/token`, {
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body,
});
if (!tr.ok) return new Response("token exchange failed", { status: 401 });
const tokens = await tr.json<{ id_token: string }>();
const to = cookie("return_to", req) || "/";
return new Response(null, { status: 302, headers: {
"Location": to,
"Set-Cookie": `username_session=${tokens.id_token}; ${OPTS}; Max-Age=28800`,
}});
}
// 2) valid session? verify and pass through to the origin
const sess = cookie("username_session", req);
if (sess) {
try {
await jwtVerify(sess, jwks, { issuer, audience: env.USERNAME_CLIENT_ID });
return fetch(req); // subrequest goes to your origin, not back to the Worker
} catch { /* expired/invalid -> re-auth */ }
}
// 3) start login (Authorization Code + PKCE)
const verifier = rand(32), state = rand(16);
const authorize = new URL(`${issuer}/oauth/v2/authorize`);
authorize.search = new URLSearchParams({
response_type: "code", client_id: env.USERNAME_CLIENT_ID, redirect_uri: redirectUri,
scope: "openid profile email", state, code_challenge: await sha256(verifier), code_challenge_method: "S256",
}).toString();
const h = new Headers({ "Location": authorize.toString() });
h.append("Set-Cookie", `pkce_verifier=${verifier}; ${OPTS}; Max-Age=600`);
h.append("Set-Cookie", `oauth_state=${state}; ${OPTS}; Max-Age=600`);
h.append("Set-Cookie", `return_to=${url.pathname}; ${OPTS}; Max-Age=600`);
return new Response(null, { status: 302, headers: h });
},
};# wrangler.toml
name = "username-sso-gateway"
main = "src/index.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"] # jose needs this
# Public config
[vars]
USERNAME_ISSUER = "https://login.username.md"
USERNAME_CLIENT_ID = "your-registered-client-id"
# Secret (only for a confidential client):
# wrangler secret put USERNAME_CLIENT_SECRET
# Bind the Worker in front of your app:
routes = [{ pattern = "app.example.com/*", zone_name = "example.com" }]
# npm i jose && npx wrangler deploy<!-- 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). --># 0. Discover the endpoints (everything below is derived from this).
curl -s https://login.username.md/.well-known/openid-configuration | jq .
# 1. Build the Authorization Code + PKCE request. Generate a verifier/challenge:
VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -binary -sha256 | base64 | tr '+/' '-_' | tr -d '=')
# 2. Send the user's browser here (response comes back to your redirect_uri):
echo "https://login.username.md/oauth/v2/authorize\
?response_type=code\
&client_id=$CLIENT_ID\
&redirect_uri=https://yourapp.com/callback\
&scope=openid+profile+email\
&state=$(openssl rand -hex 16)\
&code_challenge=$CHALLENGE\
&code_challenge_method=S256"
# 3. Exchange the returned ?code=... for tokens (add client_secret if confidential):
curl -s -X POST https://login.username.md/oauth/v2/token \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri=https://yourapp.com/callback \
-d client_id="$CLIENT_ID" \
-d code_verifier="$VERIFIER" | jq .
# 4. Verify id_token against the JWKS, or introspect the access token:
curl -s https://login.username.md/oauth/v2/keys | jq .https://login.username.md ·
discovery at /.well-known/openid-configuration ·
full worked SPA exchange in /callback.html.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.
<!-- 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). -->
You need an OIDC client on login.username.md. Two shapes,
depending on where you run the code:
| If your token exchange runs… | Client type | Auth |
|---|---|---|
| Server-side (Vercel route handler, CF Worker, any backend) | Web / confidential | PKCE + client_secret |
| Purely in the browser (SPA, the badge) | User-agent / public | PKCE only (no secret) |
| Setting | Value |
|---|---|
| Issuer | https://login.username.md |
| Authorize | /oauth/v2/authorize |
| Token | /oauth/v2/token |
| JWKS | /oauth/v2/keys |
| UserInfo | /oidc/v1/userinfo |
| Scopes | openid profile email (also phone address offline_access) |
| Redirect URI | your /callback (and /api/auth/callback for the Vercel example) |
| PKCE | S256 (required) |
/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.