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:
| Namespace | Purpose | Used 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 UI | Only this app's browser |
| /api/dev/* | Server-to-server developer keys (zwk_…) acting on behalf of an entity you own | Backend 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:
- Start sign-in: build PKCE + state, redirect the browser to /api/oauth/authorize. We run consent → passkey → profile pick.
- Handle the callback: we redirect back with a one-time code; your backend exchanges it (with your secret + PKCE verifier) at /api/oauth/token.
- 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.
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"
}{
"data": { "clientId": "orc_…", "identityLevel": "profile" },
"clientSecret": "ocs_…" // shown ONCE — store like a password
}2. Endpoint reference
| Endpoint | Purpose | Auth |
|---|---|---|
| GET /api/oauth/authorize | Start sign-in (browser redirect) | none (public navigation) |
| POST /api/oauth/token | Exchange code / refresh tokens | Basic (confidential) or client_id (public) |
| GET /api/oauth/userinfo | Fetch the signed-in profile | Bearer access_token |
| POST /api/oauth/revoke | Revoke a refresh token | Basic (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.
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 — (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 — (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
// 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)
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.nonce5. 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:
{
"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)
| error | cause & fix |
|---|---|
| invalid_request | missing/blank field — check code, redirect_uri, code_verifier |
| invalid_client | bad/missing secret, disabled client, or wrong CORS origin |
| invalid_grant | expired/used code, PKCE mismatch, redirect_uri mismatch, or refresh reuse |
| unsupported_grant_type | use authorization_code or refresh_token |
| slow_down | rate 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 see | What it usually means |
|---|---|
| Unknown client | The 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 registered | The URI must be an exact string match (scheme, host, port, path). http vs https, trailing slash, port differences all count. |
| Client disabled | Set the client's status back to active in the provider UI. |
| code_challenge required / Invalid scope | Missing PKCE params or asking for a scope outside openid profile. |
| invalid_grant on /token | Code 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 /token | Wrong/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.