Forms & CSRF
The <Form> component (@jorvel/runtime) binds a server action to a real <form> with pending / error / data state and progressive enhancement. CSRF protection comes from a signed double-submit cookie in @jorvel/security.
The <Form> component
<Form> renders a native <form>, intercepts submit with JS, serializes FormData, and runs your action. Pass a render-fn child to read { pending, error, data, reset }. Set formAction for the no-JS native-POST fallback.
import { Form, defineAction } from '@jorvel/runtime';
const createPost = defineAction(async (fd: FormData) => {
const res = await fetch('/api/posts', { method: 'POST', body: fd });
if (!res.ok) throw new Error('Create failed');
return res.json();
});
function NewPost({ csrfToken }: { csrfToken: string }) {
return (
<Form action={createPost} formAction="/api/posts" csrf={{ token: csrfToken }}>
{({ pending, error, data }) => (
<>
<input name="title" required />
<textarea name="body" />
<button disabled={pending}>{pending ? 'Saving…' : 'Publish'}</button>
{error ? <p role="alert">{String(error)}</p> : null}
{data ? <p>Published #{data.id}</p> : null}
</>
)}
</Form>
);
}CSRF — signed double-submit cookie
On a safe request, issue a token: set a (non-HttpOnly) cookie and hand the same token to the page. On an unsafe request, the client echoes it (header or hidden field) and verifyCsrf checks the echo equals the cookie. With a secret, tokens are HMAC-signed so an attacker who can only setcookies — not read the signed value — can't forge a matching pair.
import { issueCsrfToken, verifyCsrf } from '@jorvel/security';
// GET a form page — issue the token:
const { token, setCookie } = await issueCsrfToken({ secret: process.env.CSRF_SECRET });
// → render <Form csrf={{ token }} /> AND return the Set-Cookie header
// POST handler — verify before mutating:
const result = await verifyCsrf(request, { secret: process.env.CSRF_SECRET });
if (!result.ok) {
return new Response('CSRF: ' + result.reason, { status: 403 });
}
// … safe to process the mutationSafe methods (GET/HEAD/OPTIONS) always pass. For multipart / urlencoded posts where the token rides in a form field, parse the field and pass it explicitly: verifyCsrf(request, opts, formData.get(csrfFieldName())).
End-to-end wiring
import { issueCsrfToken, verifyCsrf, csrfFieldName } from '@jorvel/security';
// 1. render the page
const { token, setCookie } = await issueCsrfToken({ secret });
const html = renderPage(<NewPost csrfToken={token} />); // <Form> injects a hidden _csrf input
return new Response(html, { headers: { 'set-cookie': setCookie, 'content-type': 'text/html' } });
// 2. handle the action POST
const form = await request.formData();
const check = await verifyCsrf(request, { secret }, String(form.get(csrfFieldName()) ?? ''));
if (!check.ok) return new Response('Forbidden', { status: 403 });The CSRF cookie is intentionally readable
HttpOnly — unlike the session cookie. That is safe: the cookie carries no authority on its own, only a value that must match the echoed token.File uploads (multipart)
Parse multipart/form-data in any runtime with the dependency-free parser — fields come back as strings, files as Uint8Array.
import { parseMultipartRequest } from '@jorvel/security';
// inside an action / server route:
const { fields, files } = await parseMultipartRequest(request);
for (const f of files) {
// f: { name, filename, contentType, data: Uint8Array }
await storage.put(f.filename, f.data);
}
console.log(fields.title);Input validation
Validate action/form inputs with the built-in schema (or any { parse(input) } validator — Zod/Valibot drop in). An action is a trust boundary; validate before you touch the DB.
import { v, ValidationError } from '@jorvel/security';
import { defineAction } from '@jorvel/runtime';
const schema = v.object({ email: v.string(), age: v.number().optional() });
export const signup = defineAction(async (input: unknown) => {
const data = schema.parse(input); // throws ValidationError (status 400) on bad input
return db.insert(users).values(data);
});