Verifying proofs on the server
verifyGhostProof, replay protection stores, and where Ghost deliberately stops.
The server package is one verifier, one challenge factory, and one replay
store. It has no framework adapters and no runtime dependencies — it runs
anywhere globalThis.crypto exists.
The two endpoints you write
import {
createChallenge,
InMemoryChallengeStore,
InMemoryGhostCredentialStore,
verifyGhostProof,
} from "@0xsarwagya/ghost/server";
const AUDIENCE = "https://app.example";
const store = new InMemoryChallengeStore();
const credentials = new InMemoryGhostCredentialStore();
// GET /ghost/challenge?action=login
function challengeHandler(action: string) {
return createChallenge({ audience: AUDIENCE, action });
}
// POST /ghost/verify
async function verifyHandler(body: unknown) {
const result = await verifyGhostProof(body, {
expectedAudience: AUDIENCE,
expectedAction: "login",
challengeStore: store,
credentialStore: credentials,
});
if (!result.ok) {
return { status: 401, code: result.code };
}
// result.ghostId is proven. Do your thing: create your own session,
// upsert a row keyed by ghostId, or authorize this one action.
return { status: 200, ghostId: result.ghostId };
}verifyGhostProof returns a discriminated union — { ok: true, ghostId, credentialId, publicKey, action } or { ok: false, code, message }. It
throws only for your own misconfiguration (missing store or audience),
never for hostile input.
What the verifier enforces, in order
- Proof shape, protocol version, algorithm.
- Expiry (
CHALLENGE_EXPIRED). - Audience (
AUDIENCE_MISMATCH) and action. - Identity binding — the challenge's
ghostIdand yourexpectedGhostId. - The credential ID matches the public key, and the credential is active for the Ghost when the Ghost ID is stable.
- The Ed25519 signature over the canonical bytes.
- Nonce consumption, last (
CHALLENGE_REUSED) — so a failed proof never burns an unused challenge.
Replay stores
InMemoryChallengeStore is correct for a single process: one Map, atomic
consume, lazy sweep of expired nonces.
import type { ChallengeStore } from "@0xsarwagya/ghost/server";
// Redis: SET NX with expiry is exactly a one-time nonce.
const redisStore: ChallengeStore = {
async consume(nonce, expiresAt) {
const ttlMs = Math.max(1, expiresAt - Date.now());
const set = await redis.set(`ghost:nonce:${nonce}`, "1", "PX", ttlMs, "NX");
return set === "OK";
},
};A database row with a unique constraint on the nonce works the same way: insert succeeds → consumed; conflict → replay.
Where Ghost stops
Verification is the end of Ghost's job. Sessions, cookies, tokens, and
authorization are your application's — Ghost stays out of them so it can
stay small. A common pattern: verify once per browser session, then use
your normal session machinery keyed by ghostId.