Skip to content

Write a provider

Until the dedicated plugins ship, @valetkey/custom covers any auth system you can sign into with code. You write one function; valetkey handles storage, expiry, guardrails, and delivery.

mint(ctx, persona) signs in server-side and returns the browser state that makes that sign-in real. Most apps need one fetch to their own sign-in endpoint and the resulting cookie:

import { createSessionArtifact, custom } from "@valetkey/custom";
import { defineConfig } from "valetkey";
export default defineConfig({
app: {
name: "myapp",
origins: ["http://localhost:3000"],
},
provider: custom({
mint: async (ctx, persona) => {
const password = await ctx.secrets.get("DEV_USER_PASSWORD");
const response = await ctx.fetch("http://localhost:3000/api/sign-in", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: persona.seed.email, password }),
redirect: "manual",
});
if (!response.ok && response.status !== 302) {
throw new Error(`sign-in failed with ${response.status}`);
}
const setCookie = response.headers.getSetCookie()[0] ?? "";
const [pair = ""] = setCookie.split(";", 1);
const separator = pair.indexOf("=");
return createSessionArtifact({
origins: ctx.origins,
cookies: [
{
name: pair.slice(0, separator),
value: pair.slice(separator + 1),
domain: "localhost",
path: "/",
expires: -1,
httpOnly: true,
secure: false,
sameSite: "Lax",
},
],
meta: { email: String(persona.seed.email), role: String(persona.seed.role ?? "") },
});
},
}),
personas: {
admin: { seed: { email: "admin@myapp.test", role: "admin" } },
"free-user": { seed: { email: "free@myapp.test", role: "user" } },
},
});

Things valetkey does for you after mint() returns:

  • Clamps expiresAt to the session TTL. Returning null is fine; valetkey fills in the deadline.
  • Rejects the artifact if any cookie’s domain is not one of your configured origins.
  • Saves it with 0600 permissions and writes the audit event.

Things to know while writing it:

  • ctx.secrets.get("KEY") is the only way to read the vault. Every value it returns is auto-registered for redaction, so it cannot leak through logs or errors.
  • ctx.fetch is the standard fetch. Use redirect: "manual", because sign-in endpoints usually redirect and you want the Set-Cookie from the first response.
  • Some frameworks split the session across several cookies. Return them all; response.headers.getSetCookie() gives you the full list.
  • Apps that keep the session in localStorage instead of cookies can return localStorage: [{ origin, entries }] on the artifact.

If the persona’s user might not exist yet, implement seed. It runs on valetkey seed and should be idempotent:

custom({
seed: async (ctx, persona) => {
const adminKey = await ctx.secrets.get("ADMIN_API_KEY");
await ctx.fetch("http://localhost:3000/api/dev/users", {
method: "PUT",
headers: { authorization: `Bearer ${adminKey}` },
body: JSON.stringify(persona.seed),
});
},
mint: async (ctx, persona) => {
// ...
},
});

Both are optional. verify(ctx, artifact) returns "valid", "expired", or "invalid", useful when your app can invalidate sessions server-side. revoke(ctx, artifact) runs on valetkey logout; use it to kill the session inside your app, since valetkey already deletes the local artifact either way:

revoke: async (ctx, artifact) => {
await ctx.fetch("http://localhost:3000/api/sessions/revoke", {
method: "POST",
body: JSON.stringify({ userId: artifact.meta.userId }),
});
},

Skipping revoke is acceptable for dev; the artifact is deleted locally either way and the short TTL bounds the rest.

The exact types for all of this are in the Plugin API reference.