CorePass provider + server helpers for Auth.js (@auth/core) implementing the pending-by-default registration flow:
- CorePass first checks
HEAD /passkey/data: 200 = enrichment available (pending mode), 404 = enrichment not available (e.g.allowImmediateFinalizeenabled). - If enrichment available (200): browser does WebAuthn attestation via
POST /webauthn/startandPOST /webauthn/finish→ server stores a pending registration → CorePass app finalizes by callingPOST /passkey/datawith an Ed448-signed payload. - If enrichment not available (404): browser completes attestation and finalizes in one go via
POST /webauthn/finishwithcoreId(and optional data); no enrich step.
- Provider:
CorePass()(wraps Auth.js WebAuthn with passkey-friendly defaults) - Server helpers:
createCorePassServer()exposing handlers:startRegistration(req)finishRegistration(req)enrichRegistration(req)(your/passkey/data)checkEnrichment()(HEAD /passkey/data: 200 when enrichment available, 404 whenallowImmediateFinalizeis enabled)
- DB extension schema:
db/corepass-schema.sql
sequenceDiagram
autonumber
actor B as Browser
participant S as Your backend
participant KV as Challenge store
participant DB as CorePass store
actor A as CorePass app
A->>S: HEAD /passkey/data
S-->>A: 200 (enrichment available) or 404 (use immediate finalize in finish, not shown)
Note over A: If 200, use enrich flow below
B->>S: POST /webauthn/start { email? }
Note over B,S: Pending TTL default is 10 minutes (pendingTtlSeconds=600)
S->>KV: put reg:sid {challenge,email} ttl
S-->>B: 200 CreationOptions + Set-Cookie corepass.sid
B->>B: navigator.credentials.create()
B->>S: POST /webauthn/finish { attestation, email? }
S->>KV: get+delete reg:sid
S->>S: verifyRegistrationResponse()
S->>DB: createPendingRegistration(credentialId, publicKey, counter, aaguid, email?)
S-->>B: 200 { pending:true, enrichToken, credentialId }
A->>S: POST /passkey/data {coreId, credentialId, timestamp, userData} + X-Signature (Ed448)
Note over A,S: Only when enrichment available (HEAD returned 200)
S->>S: validateCoreIdMainnet + timestamp window
S->>S: verify Ed448 signature over canonical JSON
S->>DB: load+delete pending by credentialId
S->>S: create/link Auth.js user+account+authenticator
S->>DB: upsert CorePass identity/profile (provided_till, flags)
S->>S: (optional) POST registration webhook { coreId, refId? } (registrationWebhookRetries, default 3)
Note over S: If registrationWebhookSecret is set, include HMAC headers:\nX-Webhook-Timestamp + X-Webhook-Signature
S-->>A: 200 ok
CorePass login is normal WebAuthn: it uses the Auth.js WebAuthn callback path (action=authenticate), and resolves the user by stored authenticators.
sequenceDiagram
autonumber
actor B as Browser
participant Auth as Auth.js (@auth/core)
participant DB as Adapter DB
B->>Auth: GET /auth/webauthn-options?action=authenticate (provider=corepass)
Auth->>DB: listAuthenticatorsByUserId (optional) / challenge cookie
Auth-->>B: 200 RequestOptions + challenge cookie
B->>B: navigator.credentials.get()
B->>Auth: POST /auth/callback/corepass { action:"authenticate", data }
Auth->>DB: getAuthenticator(credentialId) + verifyAuthenticationResponse()
Auth->>DB: updateAuthenticatorCounter()
Auth-->>B: session established
Note over Auth: (optional) POST login webhook { coreId, refId? } (loginWebhookRetries, default 3)
npm install authjs-corepass-providerYou also need:
@auth/core(peer dependency)@simplewebauthn/browserin your frontend
import { Auth } from "@auth/core"
import CorePass from "authjs-corepass-provider/provider"
export const auth = (req: Request) =>
Auth(req, {
providers: [CorePass()],
adapter: /* your Auth.js adapter */,
})You mount these where you want in your app (framework-specific). The handlers are plain Web API Request -> Response.
import { createCorePassServer } from "authjs-corepass-provider"
const corepass = createCorePassServer({
adapter: /* Auth.js adapter (must implement WebAuthn + user methods) */,
// store must implement CorePassStore:
// - pending registrations (default flow)
// - coreId <-> userId identity mapping
// - profile metadata (o18y/o21y/kyc/provided_till)
//
// Built-ins:
// - d1CorePassStore(db) for Cloudflare D1
// - postgresCorePassStore(pg) for Postgres (node-postgres, etc)
// - supabaseCorePassStore(supabase) for Supabase client
store: /* CorePassStore implementation */,
challengeStore: /* CorePassChallengeStore implementation (KV/Redis/etc) */,
rpID: "example.com",
rpName: "Example",
expectedOrigin: "https://example.com",
// default: pending registrations are required
allowImmediateFinalize: false,
})
// Optional: login webhook (call from Auth.js events.signIn)
// events: {
// async signIn({ user, account }) {
// if (account?.provider === "corepass" && account?.type === "webauthn" && user?.id) {
// await corepass.postLoginWebhook({ userId: user.id })
// }
// }
// }
//
// Optional: logout webhook (call from Auth.js events.signOut)
// events: {
// async signOut({ session }) {
// // You must be able to map the logout event to a userId.
// // How you obtain userId depends on your Auth.js setup/session strategy.
// // If you have it:
// // await corepass.postLogoutWebhook({ userId })
// }
// }
export async function POST(req: Request) {
const url = new URL(req.url)
if (url.pathname === "/webauthn/start") return corepass.startRegistration(req)
if (url.pathname === "/webauthn/finish") return corepass.finishRegistration(req)
if (url.pathname === "/passkey/data") return corepass.enrichRegistration(req)
return new Response("Not found", { status: 404 })
}
export async function HEAD(req: Request) {
const url = new URL(req.url)
if (url.pathname === "/passkey/data") return corepass.checkEnrichment()
return new Response(null, { status: 404 })
}If you want to avoid manually wiring store: d1CorePassStore(...) etc, you can use the factories:
createCorePassServerD1({ db, ... })createCorePassServerPostgres({ pg, ... })createCorePassServerSupabase({ supabase, ... })createCorePassServerCloudflareD1Kv({ db, kv, ... })createCorePassServerPostgresRedis({ pg, redis, ... })createCorePassServerSupabaseUpstash({ supabase, redis, ... })createCorePassServerSupabaseVercelKv({ supabase, kv, ... })
This does not create an Auth.js adapter for you (adapters are separate packages), but it ensures the CorePass store uses the same DB client you pass in.
challengeStore is not an Auth.js provider and it is not tied to WebAuthn/Passkey provider IDs.
It’s a minimal storage interface used by this package’s custom endpoints to persist the WebAuthn challenge
between:
POST /webauthn/start(generate challenge)POST /webauthn/finish(verify attestation against expected challenge)
It must support TTL (seconds) and delete on use.
You create/access your KV/Redis client in your runtime and pass it into the helper:
- Cloudflare Workers: use an
envbinding - Node/Next.js/etc: create a Redis client using URL/token from env vars
import { memoryChallengeStore } from "authjs-corepass-provider"import { redisChallengeStore } from "authjs-corepass-provider"
import { Redis } from "ioredis"
const redis = new Redis(process.env.REDIS_URL!)
const challengeStore = redisChallengeStore({
set: (key, value, { ex }) => redis.set(key, value, "EX", ex),
get: (key) => redis.get(key),
del: (key) => redis.del(key),
})import { kvChallengeStore } from "authjs-corepass-provider"
// wrangler.jsonc:
// {
// "kv_namespaces": [
// { "binding": "COREPASS_KV", "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
// ]
// }
//
// Worker handler: env.COREPASS_KV is a KVNamespace binding.
//
// const challengeStore = kvChallengeStore(env.COREPASS_KV)import { vercelKvChallengeStore } from "authjs-corepass-provider"
import { kv } from "@vercel/kv"
// Vercel manages connection details via environment variables.
// See Vercel KV setup for the required env vars.
const challengeStore = vercelKvChallengeStore({
set: (key, value, { ex }) => kv.set(key, value, { ex }),
get: (key) => kv.get<string>(key),
del: (key) => kv.del(key),
})import { upstashRedisChallengeStore } from "authjs-corepass-provider"
import { Redis } from "@upstash/redis"
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
const challengeStore = upstashRedisChallengeStore({
set: (key, value, { ex }) => redis.set(key, value, { ex }),
get: (key) => redis.get<string>(key),
del: (key) => redis.del(key),
})Durable Objects are a good fit if you want low-latency ephemeral state close to your Worker.
import { durableObjectChallengeStore } from "authjs-corepass-provider"
// Your Durable Object must implement these routes:
// - POST /challenge/put { key, value, ttlSeconds }
// - GET /challenge/get?key=...
// - POST /challenge/delete { key }
//
// Then:
// const challengeStore = durableObjectChallengeStore(env.COREPASS_DO.get(id))If you already run on AWS and want a managed KV with TTL:
import { dynamoChallengeStore } from "authjs-corepass-provider"
import { DynamoDBClient } from "@aws-sdk/client-dynamodb"
import { DynamoDBDocumentClient, PutCommand, GetCommand, DeleteCommand } from "@aws-sdk/lib-dynamodb"
const TableName = process.env.COREPASS_CHALLENGE_TABLE!
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}))
const nowSec = () => Math.floor(Date.now() / 1000)
// This library doesn't hard-depend on AWS SDK; you provide a small adapter:
const challengeStore = dynamoChallengeStore({
put: ({ key, value, expiresAt }) =>
ddb.send(new PutCommand({ TableName, Item: { pk: key, value, expiresAt } })),
get: async (key) => {
const res = await ddb.send(new GetCommand({ TableName, Key: { pk: key } }))
const item = res.Item as { value?: string; expiresAt?: number } | undefined
if (!item?.value || typeof item.expiresAt !== "number") return null
if (item.expiresAt < nowSec()) return null
return { value: item.value, expiresAt: item.expiresAt }
},
delete: (key) => ddb.send(new DeleteCommand({ TableName, Key: { pk: key } })),
})Use a table like:
CREATE TABLE IF NOT EXISTS corepass_challenges (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_corepass_challenges_expires_at ON corepass_challenges(expires_at);Then implement:
import type { CorePassChallengeStore } from "authjs-corepass-provider"
export function sqlChallengeStore(db: {
exec: (sql: string, params?: unknown[]) => Promise<unknown>
get: (sql: string, params?: unknown[]) => Promise<{ value: string; expires_at: number } | null>
}): CorePassChallengeStore {
const nowSec = () => Math.floor(Date.now() / 1000)
return {
async put(key, value, ttlSeconds) {
const expiresAt = nowSec() + ttlSeconds
await db.exec(
"INSERT OR REPLACE INTO corepass_challenges (key, value, expires_at) VALUES (?1, ?2, ?3)",
[key, value, expiresAt]
)
},
async get(key) {
const row = await db.get(
"SELECT value, expires_at FROM corepass_challenges WHERE key = ?1",
[key]
)
if (!row) return null
if (row.expires_at < nowSec()) {
await db.exec("DELETE FROM corepass_challenges WHERE key = ?1", [key])
return null
}
return row.value
},
async delete(key) {
await db.exec("DELETE FROM corepass_challenges WHERE key = ?1", [key])
},
}
}Apply your adapter’s default Auth.js schema, then apply:
db/corepass-schema.sql(SQLite/D1)db/corepass-schema.postgres.sql(PostgreSQL/Supabase)
This adds:
corepass_pending_registrationscorepass_identities(CoreID → Auth.jsuserIdmapping)corepass_profiles(CorePass metadata likeo18y,kyc,provided_till)
allowedAaguids: defaults to CorePass AAGUID636f7265-7061-7373-6964-656e74696679. Pass a string or an array of AAGUIDs. Set tofalseto allow any authenticator.pubKeyCredAlgs: defaults to[-257, -7, -8](RS256, ES256, Ed25519).- WebAuthn registration options (optional overrides; defaults are passkey-friendly and privacy-friendly):
attestationType:"none"(default),"indirect", or"direct".authenticatorAttachment:"cross-platform"(default) or"platform".residentKey:"preferred"(default),"required", or"discouraged".userVerification:"required"(default),"preferred", or"discouraged".registrationTimeout: milliseconds; default60000(60 seconds).
allowImmediateFinalize: if enabled,finishRegistrationmay finalize immediately ifcoreIdis provided in the browser payload. This is disabled by default because it weakens the CoreID ownership guarantee (the default flow requires the Ed448-signed/passkey/datarequest). When enabled,HEAD /passkey/data(checkEnrichment) returns 404 (enrichment not available).emailRequired: defaults tofalse(email can arrive later via/passkey/data). If no email is provided, the user is created with email undefined; when a real email is provided later it is updated.requireO18y: defaults tofalse. If enabled,/passkey/datamust includeuserData.o18y=trueor finalization is rejected. Not enforced for immediate-finalize.requireO21y: defaults tofalse. If enabled,/passkey/datamust includeuserData.o21y=trueor finalization is rejected. Not enforced for immediate-finalize.requireKyc: defaults tofalse. If enabled,/passkey/datamust includeuserData.kyc=trueor finalization is rejected. Not enforced for immediate-finalize.enableRefId: defaults tofalse. When enabled, the server generates and stores arefId(UUIDv4) for the CoreID identity and can include it in webhooks. When disabled, norefIdis generated or stored.- Registration webhook options:
postRegistrationWebhooks: defaults tofalse.registrationWebhookUrl: required ifpostRegistrationWebhooks: true.registrationWebhookSecret: optional. If set, requests are HMAC-signed (SHA-256) usingtimestamp + "\\n" + bodyand includeX-Webhook-Timestamp(unix seconds) andX-Webhook-Signature(sha256=<hex>).registrationWebhookRetries: defaults to3(range1-10). Retries happen on non-2xx responses or network errors.
- Login webhook options:
postLoginWebhooks: defaults tofalse.loginWebhookUrl: required ifpostLoginWebhooks: true.loginWebhookSecret: optional. Same signing format/headers as registration.loginWebhookRetries: defaults to3(range1-10). Retries happen on non-2xx responses or network errors.
- Logout webhook options:
postLogoutWebhooks: defaults tofalse.logoutWebhookUrl: required ifpostLogoutWebhooks: true.logoutWebhookSecret: optional. Same signing format/headers as registration.logoutWebhookRetries: defaults to3(range1-10). Retries happen on non-2xx responses or network errors.
pendingTtlSeconds: defaults to600(10 minutes). Pending registrations expire after this and are dropped.timestampWindowMs: defaults to600000(10 minutes). Enrichmenttimestampmust be within this window.
The CorePass app sends:
- Body:
{ coreId, credentialId, timestamp, userData } - Header:
X-Signature(Ed448 signature)
For signature verification, the server does not use the raw request body bytes. Instead it:
- Canonicalizes JSON: recursively sorts object keys alphabetically and serializes with
JSON.stringify(...)(so it is minified, no whitespace). - Builds signature input as:
signatureInput = "POST\n" + signaturePath + "\n" + canonicalJsonBody
Then it verifies X-Signature (Ed448) over UTF-8(signatureInput).
This means the CorePass signer must sign the same canonical JSON string (alphabetically ordered + minified) and the same signaturePath (defaults to /passkey/data, configurable via signaturePath).
timestamp is required and must be a Unix timestamp in microseconds.
userData fields:
| Field | Type | Example | Notes |
|---|---|---|---|
email |
string |
user@example.com |
Optional. If omitted, user email is left undefined; if provided later, Auth.js user email is updated. |
o18y |
boolean (or 0/1) |
true |
Stored in corepass_profiles.o18y. |
o21y |
boolean (or 0/1) |
false |
Stored in corepass_profiles.o21y. |
kyc |
boolean (or 0/1) |
true |
Stored in corepass_profiles.kyc. |
kycDoc |
string |
PASSPORT |
Stored in corepass_profiles.kyc_doc. |
dataExp |
number |
43829 |
Minutes. Converted to provided_till. |
refId is not part of CorePass /passkey/data. If you need an external correlation id, enable enableRefId and deliver it via your webhooks.
Email is not required unless you set emailRequired: true. When no email is supplied at finalization, the user is created with email undefined. If a real email is provided later (e.g. in userData.email), the user is updated via adapter.updateUser. Your adapter and database must allow missing/null email.
provided_till is stored as a Unix timestamp in seconds:
provided_till = floor(now_sec) + dataExpMinutes * 60
Auth.js’ built-in WebAuthn flow normally creates the user/account/authenticator during the WebAuthn callback. CorePass intentionally delays this until enrichment, so it uses custom endpoints instead of Auth.js’ built-in “register” callback path.
- Auth.js contributing guide:
https://raw.githubusercontent.com/nextauthjs/.github/main/CONTRIBUTING.md - Auth.js built-in Passkey provider:
https://raw.githubusercontent.com/nextauthjs/next-auth/main/packages/core/src/providers/passkey.ts