Developers

Sign in with DeadArk

Add passkey-backed sign-in to your platform and receive a portable profile_id for the signed-in profile. Built on OAuth 2.1 + PKCE + OpenID Connect.

Your "API key" = your client credentials. A server app is a confidential client and holds a client_secret used on the token call. A SPA/mobile app is a public client (PKCE only, no secret). accounts.id is never exposed — the public identity is profile_id.

Which /api namespace?

Three different namespaces, three different purposes. Only the first is for third-party sign-in:

NamespacePurposeUsed by
/api/oauth/*OAuth/OIDC provider — "Sign in with DeadArk"Your other project (this guide)
/api/auth/*First-party session/passkey auth for DeadArk's own UIOnly this app's browser
/api/dev/*Server-to-server developer keys (zwk_…) acting on behalf of an entity you ownBackend scripts (not user sign-in)

How it works

Your app implements only three things — the passkey ceremony and profile picker happen on our domain, not yours:

  1. Start sign-in: build PKCE + state, redirect the browser to /api/oauth/authorize. We run consent → passkey → profile pick.
  2. Handle the callback: we redirect back with a one-time code; your backend exchanges it (with your secret + PKCE verifier) at /api/oauth/token.
  3. Fetch identity: call /api/oauth/userinfo to get profile_id + handle/name/avatar, then start your own session.

All endpoint URLs are published at /.well-known/openid-configuration; ES256 keys for verifying the id_token are at /.well-known/jwks.json.

1. Register your client

In the provider UI (Account → Settings → OAuth clients) or via the admin API. Set identityLevel: "profile" to receive profiles.

Clients are environment-specific. A client you register on localhost does not exist on production — and vice versa. Always make the /api/oauth/authorizerequest against the same origin where you registered the client. Mixing them is the #1 cause of "Unknown client" / dead-page errors.

register client
POST /api/oauth/clients
{
  "appName": "My Other Project",
  "clientType": "confidential",
  "tokenEndpointAuthMethod": "client_secret_basic",
  "identityLevel": "profile",
  "allowedOrigins": ["https://my-other-project.com"],
  "redirectUris": ["https://my-other-project.com/oauth/callback"],
  "rpId": "deadark.com"
}
response (secret shown once)
{
  "data": { "clientId": "orc_…", "identityLevel": "profile" },
  "clientSecret": "ocs_…"   // shown ONCE — store like a password
}

2. Endpoint reference

EndpointPurposeAuth
GET /api/oauth/authorizeStart sign-in (browser redirect)none (public navigation)
POST /api/oauth/tokenExchange code / refresh tokensBasic (confidential) or client_id (public)
GET /api/oauth/userinfoFetch the signed-in profileBearer access_token
POST /api/oauth/revokeRevoke a refresh tokenBasic (confidential) or client_id (public)

authorize params: client_id, redirect_uri (exact match), scope (openid profile), code_challenge + code_challenge_method=S256, plus state and nonce. On success we redirect to your redirect_uri?code=…&state=… (code TTL 2 min).

3. PKCE helper

PKCE S256 is mandatory (for confidential clients too). Generate a verifier per sign-in and store it.

lib/pkce.ts
import crypto from 'node:crypto';

const b64url = (b) => b.toString('base64url');

export function createPkce() {
  const verifier = b64url(crypto.randomBytes(32));               // store in session
  const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
  return { verifier, challenge };
}
export const randomState = () => b64url(crypto.randomBytes(16));

4. Worked example (Next.js App Router)

Start sign-in

app/login/route.ts
// app/login/route.ts  — (1) start sign-in
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { createPkce, randomState } from '@/lib/pkce';

const PROVIDER = process.env.DEADARK_ISSUER!;      // https://app.deadark.com
const CLIENT_ID = process.env.DEADARK_CLIENT_ID!;
const REDIRECT_URI = process.env.DEADARK_REDIRECT_URI!;

export async function GET() {
  const { verifier, challenge } = createPkce();
  const state = randomState();
  const nonce = randomState();

  const jar = cookies();
  jar.set('da_verifier', verifier, { httpOnly: true, sameSite: 'lax', secure: true, path: '/' });
  jar.set('da_state', state, { httpOnly: true, sameSite: 'lax', secure: true, path: '/' });

  const u = new URL('/api/oauth/authorize', PROVIDER);
  u.searchParams.set('client_id', CLIENT_ID);
  u.searchParams.set('redirect_uri', REDIRECT_URI);
  u.searchParams.set('response_type', 'code');
  u.searchParams.set('scope', 'openid profile');
  u.searchParams.set('state', state);
  u.searchParams.set('nonce', nonce);
  u.searchParams.set('code_challenge', challenge);
  u.searchParams.set('code_challenge_method', 'S256');
  return NextResponse.redirect(u);
}

Handle the callback

app/oauth/callback/route.ts
// app/oauth/callback/route.ts  — (2) exchange code, (3) fetch profile
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';

const PROVIDER = process.env.DEADARK_ISSUER!;
const CLIENT_ID = process.env.DEADARK_CLIENT_ID!;
const CLIENT_SECRET = process.env.DEADARK_CLIENT_SECRET!;     // your secret "API key"
const REDIRECT_URI = process.env.DEADARK_REDIRECT_URI!;
const basicAuth = 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64');

export async function GET(req: NextRequest) {
  const url = new URL(req.url);
  const code = url.searchParams.get('code');
  const state = url.searchParams.get('state');

  const jar = cookies();
  const verifier = jar.get('da_verifier')?.value;
  if (!code || !state || state !== jar.get('da_state')?.value || !verifier) {
    return NextResponse.json({ error: 'bad_callback' }, { status: 400 });
  }

  // (2) Exchange code (server-side). Confidential client -> Basic auth + code_verifier.
  const tokenResp = await fetch(`${PROVIDER}/api/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: basicAuth },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: CLIENT_ID,
      redirect_uri: REDIRECT_URI,
      code,
      code_verifier: verifier,
    }),
  });
  const tokens = await tokenResp.json();
  if (!tokenResp.ok) return NextResponse.json(tokens, { status: 401 });

  // (3) Who signed in?
  const userResp = await fetch(`${PROVIDER}/api/oauth/userinfo`, {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  });
  const profile = await userResp.json(); // { sub, profile_id, preferred_username, name, picture }

  // Upsert YOUR user keyed on profile.profile_id, persist tokens.refresh_token, start your session.
  jar.delete('da_verifier');
  jar.delete('da_state');
  return NextResponse.redirect(new URL('/', req.url));
}

Refresh & revoke

ts
// refresh (rotates — persist the NEW refresh_token!)
await fetch(`${PROVIDER}/api/oauth/token`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: basicAuth },
  body: new URLSearchParams({ grant_type: 'refresh_token', client_id: CLIENT_ID, refresh_token }),
});

// revoke (e.g. on logout)
await fetch(`${PROVIDER}/api/oauth/revoke`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: basicAuth },
  body: new URLSearchParams({ token: refresh_token, token_type_hint: 'refresh_token' }),
});

Verify the id_token (recommended)

ts
import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(new URL(`${PROVIDER}/.well-known/jwks.json`));
const { payload } = await jwtVerify(tokens.id_token, JWKS, {
  issuer: PROVIDER,
  audience: CLIENT_ID,
});
// payload.profile_id, payload.preferred_username, payload.nonce

5. Public clients (SPA / mobile)

  • Register with clientType: "public", tokenEndpointAuthMethod: "none".
  • No client_secret — drop the Authorization: Basic header and send client_id in the body. PKCE still required.
  • Prefer exchanging the code from a backend-for-frontend; never ship a secret to the browser.

6. What you receive

identity_level=profile userinfo / id_token claims:

userinfo
{
  "sub": "sub_<opaque, pairwise per client>",
  "profile_id": "7c1f…-uuid",        // ← stable, portable; key YOUR users on this
  "preferred_username": "nyx",
  "name": "Nyx",
  "picture": "https://…"
}

sub is opaque and pairwise (different per client). profile_id is the stable, portable identifier — key your users on it. accounts.id is never present.

7. Errors (token endpoint)

errorcause & fix
invalid_requestmissing/blank field — check code, redirect_uri, code_verifier
invalid_clientbad/missing secret, disabled client, or wrong CORS origin
invalid_grantexpired/used code, PKCE mismatch, redirect_uri mismatch, or refresh reuse
unsupported_grant_typeuse authorization_code or refresh_token
slow_downrate limited (per client + IP) — back off

8. Best practices

  • Generate a fresh verifier + state + nonce per sign-in; store server-side.
  • On callback, reject if state ≠ stored; exchange the code on your server only.
  • Never expose your client_secret to the browser — use a confidential client + backend.
  • Persist the rotated refresh token after every refresh; treat reuse as compromise.
  • Register exact redirect URIs + allowed origins; HTTPS in production.
  • Key user records on profile_id; never expect or store an account UUID.
  • Verify the id_token signature against the JWKS, and check issuer, audience, and nonce.

9. Testing

Register an identity_level: profile client, allowlist your origin + redirect URI, then hit your /loginroute — you'll be sent to us, approve, complete the passkey, pick a profile, and land back on your callback with a code. You can also run the full roundtrip with no code via the in-app OAuth Playground (Account → Settings → OAuth Playground).

10. Debugging "dead page" / common pitfalls

A blank or apparently broken page after hitting /api/oauth/authorize is almost always a JSON error response rendered in the browser, not a server crash. Open DevTools → Network → click the authorize request → read the Response body.

What you seeWhat it usually means
Unknown clientThe client_iddoesn't exist on the origin you hit. Most often: registered locally but hitting production (or vice versa). Re-register on the origin you're calling.
redirect_uri not registeredThe URI must be an exact string match (scheme, host, port, path). http vs https, trailing slash, port differences all count.
Client disabledSet the client's status back to active in the provider UI.
code_challenge required / Invalid scopeMissing PKCE params or asking for a scope outside openid profile.
invalid_grant on /tokenCode already used, expired (2 min TTL), PKCE verifier doesn't match the challenge you sent at authorize, or the redirect_uriisn't identical to the one used at authorize.
invalid_client on /tokenWrong/missing client_secret(confidential clients must send HTTP Basic), or the request came from an origin that isn't in allowedOrigins.

Sanity check before testing:

  • The origin in your /api/oauth/authorize URL matches where you registered the client.
  • Your redirect_uri is in the client's redirectUris, character-for-character.
  • You stored the code_verifier server-side before redirecting, and replay it at /api/oauth/token.
  • For a confidential client, you're sending Authorization: Basic base64(client_id:client_secret) on the token call.