Menu

Auth and sessions

Two optional SDK modules help you build logins for your own site's visitors:

The users, the registration rules, and the access rules are yours: the platform provides no user store, no roles, no password reset, no email verification, no OAuth or MFA. Site visitors never get admin cookies or ctx.admin. A worked login is in Guides.

Status (as of 2026-10-25). microhost/auth and microhost/session are implemented once in the shared SDK layer and load on the Wasm, native and Linux engines. The full register, login and logout cycle is verified on the dev VM (Wasm). On native and Linux a full cycle against real SQLite is not yet tested.

Passwords: microhost/auth

Request it with sdk: ["auth"]. It needs no database grant.

import { hashPassword, verifyPassword } from "microhost/auth";

const stored = await hashPassword(password);              // string, store it as is
const ok = await verifyPassword(password, stored);        // boolean

Limits (server owned; site config cannot raise them):

Limit Value When exceeded
Auth calls per execution 2 (every call counts, including ones that fail validation) quota_exceeded
Concurrent hash jobs, whole platform 4, with a 100 ms bounded wait for a slot host_unavailable (busy)
Deadline of one hash or verify 2 seconds host_unavailable (timeout)

Errors are thrown as Error with name HostError and a lowercase code: invalid_input, quota_exceeded, host_unavailable, host_internal, capability_denied. A hash takes real time, and it counts against your execution's wall-time limit (default 1 second), so a login handler that hashes once is fine and one that loops is not.

Unknown users: the dummy hash

A login that answers faster, or differently, for unknown email addresses reveals which accounts exist. The usual defense is to verify against a fixed dummy hash when the user is not found, and to return the same message for "unknown user" and "wrong password". The platform cannot do this for you, because it cannot tell which of your queries is an account lookup. This is author guidance, not a platform feature.

The dummy must be a valid hash in the platform profile. A string that does not match the profile is rejected before any work is done, which turns the dummy into both a 500 error and a timing oracle. A valid profile has a 16-byte salt (22 base64 characters) and a 32-byte hash (43 base64 characters). Generate one once and paste it as a constant:

# pip install argon2-cffi
from argon2 import PasswordHasher
print(PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1, hash_len=32, salt_len=16).hash("not-a-real-password"))

Check that the output begins with $argon2id$v=19$m=19456,t=2,p=1$. You can also call hashPassword once in a throwaway handler and copy the result.

const DUMMY_HASH = "$argon2id$v=19$m=19456,t=2,p=1$<22 chars>$<43 chars>";   // generate your own

const row = await one(sql`SELECT id, password_hash FROM users WHERE email = ${email}`);
const ok = await verifyPassword(password, row ? row.password_hash : DUMMY_HASH);
if (!row || !ok) return Response.json({ error: "invalid_login" }, { status: 401 });

Changing the work factor later would need a new profile and rehashing at successful login; the profile is currently fixed and there is no automatic upgrade.

Sessions: microhost/session

Request sdk: ["session"], grant the site the database capability, and create the table below in a migration. Every session call (including get and destroy with no cookie) goes through the database broker, so an absent or revoked database grant rejects with CAPABILITY_DENIED.

-- private/migrations/0001_sessions.sql
CREATE TABLE site_sessions (
  token_hash BLOB PRIMARY KEY CHECK (length(token_hash) = 32),
  user_id TEXT NOT NULL,
  data_json TEXT NOT NULL DEFAULT '{}',
  expires_at INTEGER NOT NULL
);
CREATE INDEX site_sessions_user_id ON site_sessions(user_id);

The table and column names are fixed; the platform does not create the table for you. Keep your own users table alongside it.

import { create, get, destroy, destroyAll } from "microhost/session";

create(userId: string, options?: { maxAgeSeconds?: number; data?: object }): Promise<string>  // a Set-Cookie value
get(request: Request): Promise<{ userId: string; data: object } | null>
destroy(request: Request): Promise<string>        // a clearing Set-Cookie value
destroyAll(userId: string): Promise<void>

What is stored and sent

There is no cap on sessions per user. Clean up expired rows yourself from time to time, for example in a login handler:

await ctx.db.execute("DELETE FROM site_sessions WHERE expires_at <= unixepoch()");

session.data is display information you chose at login (name, theme). Never put credentials or authorization state in it, and do not use it as the source of truth for permissions: look permissions up by userId in your own tables. To change data, create a replacement session and destroy the old one; there is no update call.

What you must still do

The platform deliberately leaves these to your code:

Granting the site database access never authenticates anyone: a public handler is public until you protect it.