CorePass provider + pending registration/enrichment helpers for Auth.js (@auth/core)
npm install authjs-corepass-providerCorePass 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. allowImmediateFinalize enabled).
- If enrichment available (200): browser does WebAuthn attestation via POST /webauthn/start and POST /webauthn/finish → server stores a pending registration → CorePass app finalizes by calling POST /passkey/data with an Ed448-signed payload.
- If enrichment not available (404): browser completes attestation and finalizes in one go via POST /webauthn/finish with coreId (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 when allowImmediateFinalize is enabled)
- DB extension schema: db/corepass-schema.sql
``mermaid
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.
`mermaid
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)
`
`bash`
npm install authjs-corepass-provider
You also need:
- @auth/core (peer dependency)@simplewebauthn/browser
- in your frontend
`ts
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.
`ts
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.
(what it is, and what it supports)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 env binding
- Node/Next.js/etc: create a Redis client using URL/token from env vars
`ts`
import { memoryChallengeStore } from "authjs-corepass-provider"
`ts
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),
})
`
`ts
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)
`
`ts
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
del: (key) => kv.del(key),
})
`
`ts
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
del: (key) => redis.del(key),
})
`
Durable Objects are a good fit if you want low-latency ephemeral state close to your Worker.
`ts
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:
`ts
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:
`sql`
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:
`ts
import type { CorePassChallengeStore } from "authjs-corepass-provider"
export function sqlChallengeStore(db: {
exec: (sql: string, params?: unknown[]) => Promise
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.js userId mapping)corepass_profiles
- (CorePass metadata like o18y, kyc, provided_till)
- allowedAaguids: defaults to CorePass AAGUID 636f7265-7061-7373-6964-656e74696679. Pass a string or an array of AAGUIDs. Set to false to allow any authenticator.pubKeyCredAlgs
- : defaults to [-257, -7, -8] (RS256, ES256, Ed25519).attestationType
- WebAuthn registration options (optional overrides; defaults are passkey-friendly and privacy-friendly):
- : "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; default 60000 (60 seconds).allowImmediateFinalize
- : if enabled, finishRegistration may finalize immediately if coreId is 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/data request). When enabled, HEAD /passkey/data (checkEnrichment) returns 404 (enrichment not available).emailRequired
- : defaults to false (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 to false. If enabled, /passkey/data must include userData.o18y=true or finalization is rejected. Not enforced for immediate-finalize.requireO21y
- : defaults to false. If enabled, /passkey/data must include userData.o21y=true or finalization is rejected. Not enforced for immediate-finalize.requireKyc
- : defaults to false. If enabled, /passkey/data must include userData.kyc=true or finalization is rejected. Not enforced for immediate-finalize.enableRefId
- : defaults to false. When enabled, the server generates and stores a refId (UUIDv4) for the CoreID identity and can include it in webhooks. When disabled, no refId is generated or stored.postRegistrationWebhooks
- Registration webhook options:
- : defaults to false.registrationWebhookUrl
- : required if postRegistrationWebhooks: true.registrationWebhookSecret
- : optional. If set, requests are HMAC-signed (SHA-256) using timestamp + "\\n" + body and include X-Webhook-Timestamp (unix seconds) and X-Webhook-Signature (sha256=).registrationWebhookRetries
- : defaults to 3 (range 1-10). Retries happen on non-2xx responses or network errors.postLoginWebhooks
- Login webhook options:
- : defaults to false.loginWebhookUrl
- : required if postLoginWebhooks: true.loginWebhookSecret
- : optional. Same signing format/headers as registration.loginWebhookRetries
- : defaults to 3 (range 1-10). Retries happen on non-2xx responses or network errors.postLogoutWebhooks
- Logout webhook options:
- : defaults to false.logoutWebhookUrl
- : required if postLogoutWebhooks: true.logoutWebhookSecret
- : optional. Same signing format/headers as registration.logoutWebhookRetries
- : defaults to 3 (range 1-10). Retries happen on non-2xx responses or network errors.pendingTtlSeconds
- : defaults to 600 (10 minutes). Pending registrations expire after this and are dropped.timestampWindowMs
- : defaults to 600000 (10 minutes). Enrichment timestamp must be within this window.
)The CorePass app sends:
- Body: { coreId, credentialId, timestamp, userData }X-Signature
- Header: (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:
`text`
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:
`text`
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.mdhttps://raw.githubusercontent.com/nextauthjs/next-auth/main/packages/core/src/providers/passkey.ts`
- Auth.js built-in Passkey provider: