Auth and sessions
Two optional SDK modules help you build logins for your own site's visitors:
microhost/authhashes and verifies passwords (Argon2id, done by the host).microhost/sessionissues and checks cookie sessions stored in your database.
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/authandmicrohost/sessionare 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
hashPassword(password: string): Promise<string>returns a standard PHC string:$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>. Store it verbatim in your users table. Every call uses a fresh random 16-byte salt.verifyPassword(password: string, storedHash: string): Promise<boolean>resolvesfalsefor a wrong password. It accepts only the platform profile: Argon2id version 19,m=19456,t=2,p=1, a 32-byte hash and a 16-byte salt. Any other or malformed string rejects with codeinvalid_input; an attacker-supplied PHC string can never choose the work factor.- Both arguments must be strings (
TypeErrorotherwise). A password is at most 1024 UTF-8 bytes and a stored hash at most 1024 bytes; larger inputs reject withinvalid_input. - The work runs in the host, off the JavaScript engine, so it costs no engine fuel and does not depend on engine limits.
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>
createstores a new session and resolves with aSet-Cookievalue, not aResponse. Append it yourself:const response = Response.redirect(new URL("/account", request.url), 303); response.headers.append("Set-Cookie", await create(String(user.id), { data: { displayName: user.name } })); return response;getreads the request'sCookieheader and resolves with{ userId, data }ornull(no cookie, malformed cookie, unknown token, expired).destroydeletes the session of the presented cookie, if any, and always resolves with a clearing cookie (Max-Age=0) for you to append, also when there was no session. Use it for logout.destroyAll(userId)deletes every session of that user ("log out everywhere"). It returns no cookie: also calldestroy(request)and append its cookie so the current browser forgets the session.
What is stored and sent
- Cookie:
__Host-mh_session=<token>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=<seconds>. NoDomainattribute. The name, path and flags are not configurable. The clearing cookie has an empty value andMax-Age=0. - Token: 32 random bytes from
crypto.getRandomValues, base64url, 43 characters. Only its SHA-256 (32 bytes) is stored, intoken_hash. A copy of the database does not contain usable tokens. - Expiry:
expires_atis computed and checked with the database clock (unixepoch()), not the guest's clock. Expired rows returnnullfromgetand are not deleted automatically. - Lifetime:
maxAgeSecondsis an integer from 1 to 2,592,000 (30 days), default 604,800 (7 days). - Limits:
userIdis a non-empty string of at most 256 UTF-8 bytes;datais a plain object whose JSON is at most 1,024 bytes (no arrays, nonull). Violations throw aTypeErrorwithcodeINVALID_INPUT. - Request parsing: the
Cookieheader is read only up to 4,096 bytes, exactly one cookie named__Host-mh_sessionis accepted, and duplicates, a wrong length, or a malformed token count as "no session". - Response: a
Set-Cookievalue is limited to 4,096 bytes. RepeatedSet-Cookieheaders stay separate through the engine and the edge.
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:
- CSRF.
SameSite=Laxis a baseline, not full protection. On every state-changing route (POST, PUT, PATCH, DELETE), require theOriginheader to equal your origin, and add a per-session CSRF token (submitted in a form field or a header, for example a double-submit cookie) where a cross-site request could cause harm. See the pattern in Guides. - Login throttling. Nothing limits login attempts. Handlers cannot see the client IP (proxy and forwarding headers are removed), so throttle by account, plus a global counter, with a small table you manage through
ctx.db, for example attempts peremailper time window. Keep the counters bounded. The per-site request rate limit (default 60 per 60 s) is only a coarse backstop. - Registration, password policy, password reset, email verification, roles. All yours.
- Same message for failures. Return an identical error for unknown users and wrong passwords.
- Authorization. A valid session proves who the visitor is, not what they may do. Check it on every protected route (
get(request), then 401 or redirect whennull).
Granting the site database access never authenticates anyone: a public handler is public until you protect it.