Anvil Cloud / Architecture
Authentication
Anvil Cloud auth has one design rule: Cell code never talks to an auth provider. Handlers declare what access they need, the runtime enforces it, and adapters verify tokens. Local development and cloud deployments run the same verification path, so "works locally" means the auth logic is actually exercised, not mocked away.
Declare access on handlers
Queries, mutations, and endpoints accept an auth declaration:
queries: {
publicStats: query({
auth: "public",
handler: async () => ({ visitors: 42 }),
}),
myNotes: query({
auth: "required",
handler: async (ctx) => {
return ctx.db.notes.where("ownerId", "=", ctx.auth.requireUser()).all();
},
}),
},
mutations: {
resetAll: mutation({
auth: { roles: ["admin"] },
handler: async () => ({ reset: true }),
}),
},
| Value | Behaviour |
|---|---|
"public" |
Runs without identity. ("none" is an endpoint alias.) |
"optional" |
Runs with or without identity; the handler decides via ctx.auth. |
"required" |
Rejects with 401 AUTH_REQUIRED before the handler runs. |
{ roles: [...] } |
Requires identity plus at least one listed role; otherwise 403 FORBIDDEN. |
Defaults: queries and mutations default to "optional" (handler-enforced); endpoints default to "required". Declarations are recorded in the manifest (authPolicies and per-endpoint auth), so adapters and tooling can see the access surface without reading handler code.
Inside handlers, ctx.auth is unchanged: identity, userId, requireUser(), hasRole(role), requireRole(role).
Local development: a real identity provider
Anvil Local runs an actual identity provider, not a mock. It generates an ES256 keypair under .anvil/local/auth/, keeps a user store, and signs real JWTs that are verified on every request. Forged or expired tokens are rejected locally exactly as they would be in production.
anvil auth add-user dev_1 --email dev@example.test --roles admin
anvil auth login dev_1 # sets ambient identity + prints a token
anvil auth token dev_1 --ttl 3600 # mints a JWT (perfect for agents and curl)
anvil auth users
anvil auth whoami
All commands support --json. An agent that needs an authenticated session is two commands away:
TOKEN=$(anvil auth token dev_1 --json | jq -r .token)
curl -H "Authorization: Bearer $TOKEN" \
-d '{"input":{}}' http://localhost:8787/_anvil/query/myNotes
The local runtime also exposes auth routes:
| Route | Purpose |
|---|---|
POST /_anvil/auth/users |
Create a user (userId, email?, roles?, claims?). |
GET /_anvil/auth/users |
List users. |
DELETE /_anvil/auth/users/:id |
Remove a user. |
POST /_anvil/auth/token |
Issue a JWT (userId, ttlSeconds?). |
GET /_anvil/auth/jwks |
Public JWKS for the local issuer. |
GET /_anvil/auth/whoami |
Resolve the bearer token or ambient identity. |
POST /_anvil/auth/as/:id |
Set the ambient dev identity (creates the user if needed). |
Ambient identity (set by login or /as/) is a convenience for browser-based dev flows; bearer tokens always win when present.
Deployed: bring any OIDC provider
The AWS adapter verifies bearer tokens against any OIDC-compliant issuer. Configuration is environment, not code:
| Variable | Meaning |
|---|---|
ANVIL_AUTH_ISSUER |
OIDC issuer URL (enables verification). |
ANVIL_AUTH_AUDIENCE |
Expected aud claim (recommended). |
ANVIL_AUTH_JWKS_URI |
Optional explicit JWKS URL; otherwise OIDC discovery is used. |
ANVIL_AUTH_USER_ID_CLAIM |
Claim mapped to userId (default sub). |
ANVIL_AUTH_EMAIL_CLAIM |
Claim mapped to email (default email). |
ANVIL_AUTH_ROLES_CLAIM |
Claim mapped to roles (defaults try roles, cognito:groups, groups). |
Cognito, Auth0, Clerk, Entra ID, and anything else speaking OIDC are configuration choices, not adapter rewrites. Tokens are verified inside the runtime (JWKS signature, issuer, audience, expiry); failed verification returns 401 with a stable error code before any handler runs.
Identity supplied in request bodies is ignored by default in deployed runtimes. The ANVIL_AUTH_ALLOW_BODY_IDENTITY=true escape hatch exists for test harnesses and nothing else.
Browser client
The generated client attaches tokens automatically:
const client = createClient({
getToken: () => localStorage.getItem("token"),
});
// or imperatively:
client.setToken(token);
Testing
The in-memory test host verifies tokens too:
const host = createInMemoryRuntimeHost({ auth: { userId: "user_1" } });
host.auth.registerToken("test-token", { userId: "user_2", roles: ["admin"] });
Unknown tokens throw, so a missing registerToken shows up as a failing test rather than a silently authenticated one.
Extending to other providers and platforms
The contract is small on purpose:
TokenVerifier(@anvil-cloud/auth):verifyToken(token) → VerifiedToken. The OIDC implementation covers most providers; non-OIDC providers implement this one interface.AuthAdapter(runtime host):current()plus optionalverifyToken(). A future platform adapter supplies its own and nothing else changes.- Claims mapping is data (
ClaimsMapping), so provider quirks live in configuration.
Current limits
- No session/refresh-token management; Cells receive verified identities, token lifecycle belongs to your provider or client.
- No built-in login UI; the local IdP issues tokens directly, and deployed apps use their provider's hosted flows.
- Role checks are membership checks; fine-grained permission policies are on the roadmap.