Docs

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.

What you get

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.

MCP server

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.

Validate tokens

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.

Trust the identity

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.

  • Key your users off sub, or bind sub to the email the first time you see it and reject a different sub later claiming the same address.
  • Never attach an unverified email to an existing or privileged account — admin allowlists, domain-based entitlements, prior customers.
  • Unverified and unknown → safe to create a fresh, unprivileged user.
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);

App sign-in

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.

Endpoints

Everything below is per app, on your own subdomain.

Discovery/.well-known/oauth-authorization-server
JWKS/.well-known/jwks.json
Authorize/authorize
Token/token
Register/register  RFC 7591
Revoke/revoke  RFC 7009
Sign-in page/

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.

Clients & registration policy

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.

  • Open (default) — anything may self-register. Required for MCP connectors to work.
  • Only my addresses — self-registration allowed only from callback origins you list.
  • Off — only clients you create. Right for an app with no agent integration.

Revoking a client stops new sign-ins and revokes its refresh families; existing access tokens expire on their own within the hour.

Set up by chat

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.

Limits

The free tier — which is all there is today — covers:

  • 3 apps per account.
  • 10,000 monthly active users per account, counted across your apps. A user counts once in a calendar month, on the day they sign in.
  • AuthPop branding on the 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.

Fair-use rate limits

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.

  • Sign-in and enrollment ceremonies: 60 a minute — about 30 sign-ins a minute through one address.
  • Token requests: 120 a minute (sized so a whole office's sessions can all refresh at once after an outage).
  • Self-registration by apps and agents: 10 a minute, 50 a day.
  • Verification-code requests: 10 a minute, 300 a day. Per address: 6 guesses at a code, 60 seconds between sends, and 15 codes actually sent a day. A request we refuse does not count against that last one, because nothing was sent.

OAuth clients

  • 20 clients you create, per app.
  • Clients that register themselves are not capped by count — MCP clients register once per user connection, so a cap there would break your app precisely when it got popular. Instead, a self-registered client that never completes a sign-in is cleaned up after 7 days. One that has signed somebody in is kept, however long it then sits idle.

Something you need raised? Email support@authpop.com. Security reports go to security@authpop.com — see security.txt.