Everything your server does, on one page. This is extracted from a real integration — CheckAEO runs its MCP auth on AuthPop in production — rather than written speculatively.
Name an app and it gets a complete OAuth 2.1 authorization server at
yourapp.authpop.dev: hosted passkey login, email verification, consent, dynamic client
registration, PKCE, rotating refresh tokens, and a public JWKS. You write no auth code — you point at
it, and you check a signature.
Nothing runs in your request path. Tokens are ES256 JWTs your server verifies locally against a cached JWKS. AuthPop being slow or down cannot slow down your API.
Two pieces of metadata make Claude, ChatGPT, and Cursor run the whole flow for you. First, answer unauthenticated calls with a 401 that says where sign-in lives:
HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata= "https://api.yourapp.com/.well-known/oauth-protected-resource"
Then serve that document, naming your app's sign-in server:
GET /.well-known/oauth-protected-resource
{
"resource": "https://api.yourapp.com/mcp",
"authorization_servers": ["https://yourapp.authpop.dev"],
"bearer_methods_supported": ["header"]
}
That 401 is the trigger: an OAuth-capable client reads it, discovers your issuer, registers itself, runs PKCE, and opens the passkey page. You implement neither discovery nor registration nor PKCE.
Access tokens are ES256 JWTs. Verify the signature against your app's JWKS (cache it ~10 minutes), then check the issuer, expiry, and audience. This is the whole thing:
// ~40 lines, no dependencies — this is CheckAEO's production version async function verifyAuthPopJwt(token, { issuer, resource }) { const b64u = (s) => Uint8Array.from( atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - s.length % 4) % 4)), (c) => c.charCodeAt(0)); const [h, p, sig] = token.split('.'); const header = JSON.parse(new TextDecoder().decode(b64u(h))); const payload = JSON.parse(new TextDecoder().decode(b64u(p))); if (header.alg !== 'ES256') return null; if (payload.iss !== issuer) return null; if (!payload.exp || payload.exp < Date.now() / 1000) return null; // Exact resource only — see the warning below. if (String(payload.aud || '') !== resource) return null; const jwks = await fetchCached(issuer + '/.well-known/jwks.json'); const jwk = jwks.keys.find((k) => k.kid === header.kid); if (!jwk) return null; const key = await crypto.subtle.importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, b64u(sig), new TextEncoder().encode(h + '.' + p)); return ok ? payload : null; }
Bind the audience exactly. Do not accept a cli_… audience as a fallback. AuthPop
sets aud to the RFC 8707 resource the client requested, or to the client id
when none was sent — so a loose check would accept a token minted for any client of your
issuer, including one an attacker registered and phished a single consent for.
MCP clients have been required to send resource since spec 2025-06-18, so an exact
check is safe.
The payload carries sub (a stable AuthPop user id), email, and
email_verified. Read this part carefully — it is the one place a integration can go wrong
in a way that matters.
A passkey proves possession of a device, not ownership of an inbox. AuthPop verifies email
with a code at signup and refuses to issue tokens for unverified addresses, so
email_verified is trustworthy — but your own defence in depth should not depend on that
alone.
sub, or bind sub to the email the first time
you see it and reject a different sub later claiming the same address.const payload = await verifyAuthPopJwt(bearer, {
issuer: 'https://yourapp.authpop.dev',
resource: 'https://api.yourapp.com/mcp',
});
if (!payload) return unauthorized();
if (!payload.email_verified) return unprivilegedUser();
const user = await getOrCreateUser(payload.email);
The same app also does ordinary web login — it is one authorization server, not two. Run a standard
authorization-code flow with PKCE against your issuer; discovery lives at
/.well-known/oauth-authorization-server, so most OAuth libraries need only the issuer URL.
For a first-party app, create the client yourself in the dashboard (name + callback URL) rather than relying on self-registration, and set the registration policy below to Only my addresses or Off.
Everything below is per app, on your own subdomain.
Access tokens last 1 hour. Refresh tokens rotate on every use and expire 3 days after their LAST use, so an app in regular use never re-authenticates and an abandoned one goes quiet on its own. Each chain also belongs to a family with a hard 30-day ceiling that rotation cannot extend, so even a connector in constant use re-authenticates monthly. Replaying a rotated-out token revokes the entire family, which is how a stolen token gets caught.
Anything that signs users in to your app is an OAuth client. AI assistants register themselves; your own web app should not have to. The dashboard lists both, with a callback URL, when it was added, and when it last signed someone in — and lets you revoke any of them.
Revoking a client stops new sign-ins and revokes its refresh families; existing access tokens expire on their own within the hour.
AuthPop is itself an MCP server, so you can create and inspect apps from your assistant instead of
the dashboard. Add https://authpop.com/mcp as a connector — signing in with your passkey
is the setup.
You: Set up sign-in for my app "recipeboard" Claude: Done — your sign-in server is live at recipeboard.authpop.dev. I've added the two lines your API needs and the JWT check.
Tools: create_app, list_apps,
get_quickstart.
The free tier — which is all there is today — covers:
yourapp.authpop.dev sign-in and consent pages.Going over the user limit does not interrupt anybody. We do not throttle sign-ins, refuse tokens, or disable an app — breaking your product to make a point about a limit is not something we are willing to do. You get a note in your dashboard, adding a new app pauses, and we talk. Your dashboard shows the count per app so it is never a surprise.
Per app, per IP address — so that one app's traffic can never eat another's, and a whole office behind one address is not mistaken for one very busy person. Burst limits are per minute and deliberately loose — they are counted per Cloudflare location and settle over a few seconds, so ordinary traffic sails past the printed number and only a genuine flood is damped. Daily caps are exact, counted atomically.
Something you need raised? Email support@authpop.com. Security reports go to security@authpop.com — see security.txt.