JORVEL

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.

tsx
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.

ts
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 mutation

Safe 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

ts
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

Double-submit requires the page to read the token, so the CSRF cookie is NOT 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.

ts
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.

ts
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);
});