@crypto-signerkit/server
v0.4.0
Published
Server SDK for SignerKit — verify auth, manage action-bound step-up intents, and sign within delegated policy.
Maintainers
Readme
@crypto-signerkit/server
Server SDK for SignerKit — verify login tokens from your frontend, look up
users, and sign for users within a delegated policy. The client is configured
with your public App ID (pk_) and server-only App Secret (sk_) for online
API calls. Offline token verification has a different boundary, described
below.
Current public contract: 0.4.0.
Install
npm i @crypto-signerkit/serverUsage
import { SignerKitClient } from '@crypto-signerkit/server'
const signerkit = new SignerKitClient({
apiUrl: 'https://api.signerkit.com',
appId: 'pk_...',
appSecret: 'sk_...', // server-side only — never ship to the browser
})
// Verify locally against JWKS (the default resolver fetches/caches public JWKS as needed)
const { userId, appId: internalAppId } =
await signerkit.verifyAuthToken(tokenFromFrontend)
// Look up a user + their wallets
const user = await signerkit.getUser(userId)
const byAddr = await signerkit.getUserByWalletAddress('0x...')
// Automation: sign for a user within a policy you were granted, without holding the key
const signature = await signerkit.walletApi.signDelegated({
delegationId, chain, kind, payload, authzPrivKey,
})Token and app boundaries
verifyAuthToken verifies the JWT signature, issuer, audience, access-token
marker, expiry, and required tenant claims. It does not make an
app-authenticated authorization call or use the configured App Secret as a
second check. Unless you inject a static JWKS, the default resolver may fetch
and cache SignerKit's public JWKS. The returned appId is the internal app UUID
from the JWT app_id claim; it is not the public pk_ value in
SignerKitClient options, so do not compare those two values.
The public App ID and x-signerkit-app-id header are app locators, not secrets
or additional authentication factors. If your backend accepts ordinary login
tokens for only one sibling app, compare the verified internal UUID with that
app's internal UUID from your trusted configuration. Refresh sessions remain
app-bound.
Step-up has a stronger online contract: its user-facing ceremony checks the exact app and active session, while backend create/consume calls require the matching app credentials. A sibling app mismatch is rejected. This exact step-up binding does not turn the public App ID itself into a credential.
One-time step-up intents
For a sensitive business operation, the backend creates an intent bound to its own authoritative request fields. The browser verifies the intent with the server-selected email or TOTP method, then the backend consumes it online with the same fields:
Step-up proves recent reauthentication, not informed consent to transaction details. Before starting the ceremony, load the pending operation from your backend and show its exact human-readable action, resource, amount, destination, network, and fees (as applicable) in your own trusted confirmation UI. Hash the same authoritative operation details plus an immutable operation ID into the binding. If any displayed field changes, abandon that intent and create a new one. The public intent and generic OTP email intentionally omit these details, so neither an OTP prompt nor successful consumption is approval by itself.
import { SignerKitClient, hashStepUpBinding } from '@crypto-signerkit/server'
const binding = hashStepUpBinding({ operationId, requestedEmail })
const intent = await signerkit.createStepUpIntent({
accessToken,
expectedUserId,
action: 'account.email.change',
binding,
minimumAssurance: 'reauth',
idempotencyKey: creationAttemptId,
})
// Send only intent.intentId to the authenticated browser. After its ceremony:
const proof = await signerkit.consumeStepUpIntent({
intentId: intent.intentId,
expectedUserId,
action: 'account.email.change',
binding,
minimumAssurance: 'reauth',
idempotencyKey: operationId,
})
// Consume is necessary, but not sufficient authorization.
await recheckAuthorization({ actorId: proof.userId })
await changeEmailIdempotently({ operationId, requestedEmail })Creation requires both the user's access token and the app credentials.
Consumption requires app credentials and is one-time: the same consume key
replays the same result, while a different key is rejected. SignerKit does not
replace resource ownership checks or business-operation idempotency.
hashStepUpBinding applies the SignerKit domain separator and RFC 8785 JSON
canonicalization so both calls use an exact, stable SHA-256 binding.
minimumAssurance: 'reauth' selects email OTP for a user without TOTP and
selects the stronger TOTP method when one is enrolled. Use 'mfa' only after
the staged TOTP enrollment flow; it never falls back to email.
action must be 1–100 characters and match
[a-z0-9][a-z0-9._:-]*. Each idempotency key must be 16–200 printable ASCII
characters. Persist both keys with the pending operation: use one stable key
for creation retries and a separate stable business-operation key for consume.
Creation can commit before the caller receives a response. Keep the creation
idempotency key with the pending business operation and retry with that same key
after an unknown network outcome or email_delivery_pending; never mint a new
key just because a response was lost. SignerKitError exposes the stable
code, HTTP status, optional retryAfter seconds, and—when the API received
the request—an optional intentId. Honor retryAfter. Treat
email_delivery_failed as a failed ceremony and start a genuinely new business
attempt only through your normal user-confirmed flow.
Irreversibly remove a wallet (0.4.0)
Wallet removal uses a reserved, atomic workflow. It is irreversible and has no restore API: after success, the wallet is removed from SignerKit, disappears from public wallet reads, and can no longer sign. In every deployed environment, SignerKit permanently retains the wallet tombstone, its HD seed or imported ciphertext, and canonical deletion facts. Removal is not cryptographic key destruction or data erasure.
The App Secret and deleteWallet call are server-side only. The authenticated
browser sends its current access token to your backend over your own protected
channel; do not log or persist that token. The complete flow is:
// 1) BROWSER — use your authenticated backend channel.
const { getAccessToken } = useSignerKit()
const accessToken = await getAccessToken()
await backend.startWalletRemoval({
walletId,
accessToken,
})
// 2) SERVER — before the first request, transactionally insert-or-read your
// pending operation and both stable keys. This partner-owned function must
// durably commit before it returns; on conflict it returns the existing row.
import { randomBytes } from 'node:crypto'
import {
SignerKitClient,
hashWalletDeletionBinding,
} from '@crypto-signerkit/server'
// This is your immutable, server-authoritative business-operation ID.
const removal = await loadOrCreateWalletRemoval({
operationId: walletRemovalOperationId,
walletId,
create: () => ({
createIdempotencyKey:
'wallet-delete-create:' + randomBytes(16).toString('base64url'),
deleteIdempotencyKey:
'wallet-delete:' + randomBytes(16).toString('base64url'),
}),
})
// 3) SERVER — verify the token, derive its user ID, then load the wallet identity.
const { userId } = await signerkit.verifyAuthToken(accessToken)
const user = await signerkit.getUser(userId)
const wallet = user?.wallets.find((candidate) => candidate.id === walletId)
if (!user || !wallet) throw new Error('wallet not found')
const binding = hashWalletDeletionBinding({ userId, wallet })
// 4) SERVER — the access token proves the user; configured App credentials
// authenticate this backend request. `reauth` selects Email OTP or enrolled TOTP.
const intent = await signerkit.createStepUpIntent({
accessToken,
expectedUserId: userId,
action: 'wallet.delete',
binding,
minimumAssurance: 'reauth',
idempotencyKey: removal.createIdempotencyKey,
})
await walletRemovalStore.recordIntent(removal.operationId, intent.intentId)
// Return only intent.intentId to the same authenticated browser session.
// 5) BROWSER — complete the existing Step-up ceremony.
const { verify } = useStepUp(intent.intentId)
await verify(code)
// 6) SERVER — reload the same durable row on every finish/retry request.
const readyRemoval =
await walletRemovalStore.loadRequired(walletRemovalOperationId)
const deleted = await signerkit.deleteWallet({
walletId: readyRemoval.walletId,
intentId: readyRemoval.intentId,
idempotencyKey: readyRemoval.deleteIdempotencyKey,
})
// 7) BROWSER — refresh only after backend success.
const { refresh } = useWallets()
await refresh()The create and delete idempotency keys are separate, stable request IDs. Each
must contain at least 128 bits of cryptographic randomness, be 16–200 printable
ASCII characters, and remain non-secret and non-PII.
loadOrCreateWalletRemoval is illustrative partner storage, not an SDK
method: implement it as an atomic insert-or-read whose commit finishes before
the first createStepUpIntent call. Persist the returned intent ID before
giving it to the browser. A same-key replay of the same delete request returns
the original deletedAt with replayed: true; reusing the key for another
wallet/intent returns idempotency_conflict, while retrying a consumed intent
under a different key is rejected.
wallet.deleted is an opt-in, merchant-wide webhook: subscribe every active
app that must invalidate its wallet cache. The owner security notice uses
durable at-least-once delivery and can very rarely duplicate after recovery
beyond the email provider's idempotency window.
Full docs: https://signerkit.com.
License
MIT
