A runnable Next.js example for collecting credentials with @onkernel/vault-react. The browser renders Kernel's unstyled credential form while a same-origin backend keeps the Kernel API key private and maps one-time bearer capabilities to vault items.
It includes two flows that share the same backend pieces:
/credentials/collect: the page sends entered values to this backend, which forwards them to Kernel asvalue./credentials/collect-encrypted: the page encrypts values in the browser to the vault's public key, this backend forwards the ciphertext asencrypted_value, and only Kernel can decrypt it. Use this when plaintext must never reach your backend, proxies, or request logs. See Encrypted values.
Requires Bun and Node.js 20.9 or newer.
cp .env.example .env.local
bun install
bun run devIn a second terminal, create a 15-minute collection link:
bun run create-requestOpen the printed URL. Add --encrypted (bun run create-request -- --encrypted) for a link to the encrypted flow. Mock mode exercises the complete browser/backend flow, including encryption, without calling Kernel. Do not enter real credentials in mock mode.
- Set
MOCK_VAULT=falseandKERNEL_API_KEYin.env.local. - Create a credential item in a Kernel vault.
- Generate a link for that item:
bun run create-request -- <vault-id-or-name> <item-key>
bun run create-request -- --encrypted <vault-id-or-name> <item-key>The command retrieves the item's immutable ID, generates 32 random bytes, stores only the SHA-256 token digest, and prints the raw token once in this form:
https://app.example.com/credentials/collect#token=<43-character-base64url-token>
The fragment is not sent in page requests or referrers. The page sends it only in an Authorization: Bearer <token> header to the same-origin /api/credential-requests/current endpoint.
app/credentials/collectreads and clears the fragment capability and rendersCredentialForm.app/api/credential-requests/currentserves safe item data and accepts versioned field edits.lib/collection-handler.tsvalidates the bearer, origin, target, item ID, version, field names, value sizes, and body size.lib/vault-client.tscontains real and mock vault adapters. Only the real adapter receivesKERNEL_API_KEY. The mock adapter generates its own key pair and decryptsencrypted_valuethe way Kernel does.app/credentials/collect-encryptedandapp/api/credential-requests/current-encryptedare the encrypted flow.lib/encrypted-submission.tsturns aCredentialFormsubmission intoencrypted_valuefields withjose.lib/request-store.tsdefines the durableCollectionRequestStoreboundary.scripts/create-request.tscreates a short-lived link without persisting the raw token.
Successful submissions mark the mapping consumed. Kernel writes include both the rendered version and immutable item ID, so a stale form or a deleted-and-recreated key cannot redirect a write.
The encrypted flow differs from the default flow in three places:
GET /api/credential-requests/current-encryptedreturns{ item, encryption_key }. The backend gets the key fromkernel.vaults.retrieveEncryptionKey(vaultId)(GET /vaults/{id_or_name}/encryption_key). Each vault has its own P-256 key; it is public and stable for the vault's lifetime.- On submit, the page encrypts every entered value as a compact JWE (
alg: ECDH-ES,enc: A256GCM, the vault'skidin the header). Clearing an optional field stays{ "value": null }. - The backend passes each field through unchanged, and
kernel.vaults.items.updatesends it to Kernel, which decrypts and validates it exactly likevalue:
{ "version": 3, "fields": { "password": { "encrypted_value": "eyJhbGciOiJFQ0RILUVTIi..." } } }The encrypted route rejects plaintext value strings, so a misconfigured page cannot silently fall back to plaintext. Kernel rejects ciphertext encrypted for a different vault with 400 encryption_key_mismatch, which this example reports as invalid.
Requires @onkernel/sdk 0.122.0 or newer and HTTPS (WebCrypto needs a secure context; localhost also works). The page's JavaScript still sees raw values before encrypting them, so keep the collection route free of third-party scripts. If your backend already holds plaintext, use the default flow.
FileCollectionRequestStore is intentionally local-only. Its atomic file replacement prevents torn files, but it does not coordinate multiple processes and most serverless filesystems are ephemeral.
Before deployment, implement CollectionRequestStore with your database and replace the construction in lib/runtime.ts. Persist:
{
id,
tokenDigest,
vaultId,
itemKey,
itemId,
expiresAt,
consumedAt,
revokedAt,
}Add any signed-in application user ID needed by your authorization model. Verify that user in addition to the bearer on every read and write. Make consumption and revocation atomic in the database.
- Set
APP_ORIGINto the exact HTTPS origin serving the page. - Set
MOCK_VAULT=falseand provideKERNEL_API_KEYonly to the server runtime. - Replace the file store with a durable database implementation.
- Deliver links through a channel suitable for password-reset links and keep expiry short.
- Exclude URLs, fragments, authorization headers, request bodies, credentials, and upstream errors from logs, analytics, traces, and session replay.
The customer page and backend receive raw entered values. They are trusted application code, not an isolated Kernel collector. Keep the collection route free of third-party scripts and preserve the response's no-store and no-referrer policies.
bun run typecheck
bun run test
bun run buildMIT