Authentication
One env var and six endpoints separate a scaffolded workspace from a working OAuth sign-in — and the boundaries fail loud with typed errors instead of degrading to a silent anonymous session. Auth is the part of a backend where "compiles and demos fine" and "actually holds" diverge most easily, so NetScript puts the conventions in the contract rather than in notes an agent has to remember to apply.
NetScript ships authentication as a first-class official plugin: netscript plugin install @netscript/plugin-auth
adds an auth-api oRPC service that boots on port 8094 alongside your workers, sagas, and
triggers. The plugin is pure-backend — it owns the session lifecycle, OAuth/OIDC redirect
flow, and a stable five-endpoint REST/RPC surface, but it does not ship UI. You compose
exactly one active backend (single-active-backend), chosen by an environment variable, and
the service exposes the same contract no matter which backend is wired in.
alpha
The design follows the framework's contracts-first doctrine: a core seam package
(@netscript/plugin-auth-core) defines the AuthBackendPort, three interchangeable adapter
packages implement it, and the @netscript/plugin-auth plugin composes the selected one into a
running service. Of the three adapters, only the KV-OAuth backend implements the full
interactive sign-in/callback flow today; the WorkOS and better-auth adapters are
non-interactive by design.
Where agent-built auth goes wrong
Auth is a security-sensitive surface, and security-sensitive surfaces have a specific
build-efficiency failure shape: the mistake does not stop the build. A missing job queue fails a
request; a mis-wired sign-in flow ships. Supabase's published agent-skills guidance names the
pattern for its own platform — agents building on it skip RLS policies, hallucinate CLI commands,
and create views without security_invoker = true — and its remedy is a skill: reference material
the agent loads so it applies the conventions at the right moment. NetScript takes the other route
for the same problem: move the conventions out of the agent's working memory and into the shipped
contract, so the common wrong turns either don't type-check, don't configure, or fail loud at
runtime with a typed error.
Concretely, the contract closes off the classic failure modes:
- The silent anonymous fallback. An agent wiring auth by hand tends to catch the failure and
degrade to an unauthenticated default.
backend.authenticate()cannot express that: it returns a typedAuthnResult—{ ok: true, principal }or{ ok: false, reason }— never a silent anonymous principal. - Calling a capability the backend doesn't have. Asking a non-interactive backend (WorkOS,
better-auth) to run the interactive sign-in returns a typed
AUTH_PROVIDER_ERROR, and direct session mutations throwAuthBackendOperationUnsupportedError— a capability boundary, not a no-op that looks like success. - Two half-wired providers. There is nothing to reconcile:
NETSCRIPT_AUTH_BACKENDselects exactly one active backend (single-active-backend), and every backend normalizes to the samePrincipal, so downstream authorization code is identical regardless of provider. - Hand-rolled session cookies. The session cookie is
__Host-ns_session; the__Host-prefix makes the browser itself refuse a cookie with aDomain, a non-rootPath, or a plain-HTTP origin — the misconfiguration fails to set rather than silently weakening. - Logging raw subjects. The audit surface (
createAuthTelemetry) redacts by construction — subject hashes, stripped token-bearing claims — and runs as a no-op until a salt is configured, because an un-salted hash would be worse than nothing.
Each item above is the shipped behavior of the published packages, documented mechanism-by-mechanism in the sections below — start with
the authentication model for why the seam is shaped this way.
What it is
Auth is a port-and-adapter seam, not a monolith. The core package
@netscript/plugin-auth-core is contract-only: it defines the AuthBackendPort (and the four
sub-ports it composes — provider registry, session store, token crypto, and principal mapper),
the normalized AuthSession and Principal shapes, the AUTH_SESSION_STATES, the auth oRPC
contract, the config schemas, and the stream-event schemas. It contains no provider SDK code.
Each backend adapter (@netscript/auth-kv-oauth, @netscript/auth-workos,
@netscript/auth-better-auth) implements AuthBackendPort against a concrete identity provider,
and the @netscript/plugin-auth plugin selects exactly one of them at boot via
NETSCRIPT_AUTH_BACKEND and serves it as the auth-api service. Because every backend produces
the same Principal, downstream services authorize identically regardless of which provider is
wired in. The full rationale — why auth is pure-backend, what the single-active boundary buys you,
and how InteractiveFlowPort gates the redirect flow — is in
Learn → / Do →
Add it in two shapes
The fastest path is the provider preset: add the plugin, set NETSCRIPT_AUTH_BACKEND
(it already defaults to kv-oauth), and hand a preset like providers.google({...}) to
createKvOAuthBackend. The advanced shape uses the same factory but defines a custom OIDC
provider explicitly with defineOAuthProvider when your identity provider is not one of the
fourteen built-in presets.
// Add the plugin from the workspace root:
// netscript plugin install @netscript/plugin-auth
// Default backend is already kv-oauth (NETSCRIPT_AUTH_BACKEND=kv-oauth).
import { createKvOAuthBackend, getRequiredEnv, providers } from '@netscript/auth-kv-oauth';
// One of 14 presets: github, google, gitlab, discord, slack, spotify,
// facebook, twitter, auth0, okta, awsCognito, azureAd, logto, clerk.
export const backend = await createKvOAuthBackend({
provider: providers.google({
clientId: getRequiredEnv('NETSCRIPT_AUTH_CLIENT_ID'),
clientSecret: getRequiredEnv('NETSCRIPT_AUTH_CLIENT_SECRET'),
redirectUri: getRequiredEnv('NETSCRIPT_AUTH_REDIRECT_URI'),
}),
});
// backend.name === 'kv-oauth' — the only adapter with backend.interactive.
// Same factory, but define the OIDC provider yourself when your IdP
// is not one of the built-in presets.
import { createKvOAuthBackend, defineOAuthProvider, getRequiredEnv } from '@netscript/auth-kv-oauth';
const provider = defineOAuthProvider({
id: 'my-idp',
clientId: getRequiredEnv('NETSCRIPT_AUTH_CLIENT_ID'),
clientSecret: getRequiredEnv('NETSCRIPT_AUTH_CLIENT_SECRET'),
authorizationEndpoint: getRequiredEnv('NETSCRIPT_AUTH_AUTHORIZATION_ENDPOINT'),
tokenEndpoint: getRequiredEnv('NETSCRIPT_AUTH_TOKEN_ENDPOINT'),
userInfoEndpoint: getRequiredEnv('NETSCRIPT_AUTH_USERINFO_ENDPOINT'),
redirectUri: getRequiredEnv('NETSCRIPT_AUTH_REDIRECT_URI'),
scopes: ['openid', 'profile', 'email'],
});
export const backend = await createKvOAuthBackend({ provider });
Sign in, resolve the session, sign out
Once the plugin is wired, your front end or a typed client drives the five-endpoint surface. A sign-in begins the redirect, the provider returns to your callback, the service mints a session and sets the cookie, and every subsequent request resolves the current session from that cookie. The snippet below is a copy-ready client flow against the REST surface.
// app/sign-in.ts — drive the auth-api REST surface from the browser
const AUTH = 'http://localhost:8094/api/v1/auth';
// 1. Begin sign-in. The kv-oauth backend responds with a provider redirect;
// follow it to authenticate with the IdP.
location.href = `${AUTH}/signin`;
// 2. The IdP redirects back to /api/v1/auth/callback, which mints the
// session and sets the __Host-ns_session cookie automatically.
// 3. Resolve the current session on any later request (cookie is sent
// automatically; the response is { authenticated, user, session }).
const me = await fetch(`${AUTH}/me`, { credentials: 'include' })
.then((r) => r.json());
if (me.authenticated) {
console.log('signed in as', me.user.subject, '— state', me.session.state);
}
// 4. Sign out — the cookie authenticates the call; it revokes this session and clears the cookie.
await fetch(`${AUTH}/signout`, { method: 'POST', credentials: 'include' });
// server/auth-guard.ts — use the resolved backend directly in app code
import { AuthBackendOperationUnsupportedError } from '@netscript/plugin-auth-core/ports';
import { backend } from './backend.ts';
// Resolve the current session from an incoming Request via the session store.
const session = await backend.sessions.getSession({ request: incoming });
if (session?.state === 'active') {
// Map the normalized session to a NetScript Principal for authorization.
const { principal } = backend.principalMapper.mapSessionToPrincipal(session);
console.log(principal.subject, principal.scopes, principal.roles);
}
// Interactive sign-in is optional capability — guard before you call it.
if (!backend.interactive) {
throw new AuthBackendOperationUnsupportedError(
backend.name,
'interactive.signIn',
'The active backend authenticates sessions only.',
);
}
const redirect = await backend.interactive.signIn(incoming);
Key types first — the core auth contract
Every backend normalizes to the same two shapes the rest of NetScript depends on: an
AuthSession (what the store persists) and a Principal (what services authorize against). The
AuthSession is mapped to a Principal by the backend's principalMapper, and the
authenticate() method on every backend returns an AuthnResult. These are the contract-only
types from @netscript/plugin-auth-core — confirmed against the live export surface.
| Name | Type | Description |
|---|---|---|
subject |
string |
Stable subject identifier — a user id, service id, or API-key id. |
scopes |
readonly string[] |
Granted scopes for per-operation RPC/REST permission checks. |
roles |
readonly string[] |
Granted roles for role-based access checks. |
scheme |
'api-key' | 'bearer' | 'trusted-header' | 'custom' |
How the principal was established. Auth-plugin backends use 'custom' with the claim bag below. |
claims |
Readonly |
Opaque verified claims — organization/tenant id, session id, provider permissions, or normalized WorkOS/better-auth metadata. |
| Name | Type | Description |
|---|---|---|
id |
string |
Stable session id; the value an opaque session token resolves to. |
userId / subject |
string |
User id and stable subject the session belongs to. |
state |
AuthSessionState |
Lifecycle state: 'active' | 'expired' | 'revoked' (from AUTH_SESSION_STATES). |
scopes / roles |
readonly string[] |
Scopes and roles carried into the mapped Principal. |
claims |
Readonly |
Verified provider claims preserved on the session. |
issuedAt / expiresAt |
string (ISO) |
Issue and expiry timestamps; refreshedAt / revokedAt are set on those transitions. |
accountId / providerId |
string? |
Optional linked provider account and provider id. |
traceparent / tracestate |
string? |
Optional W3C trace context carried for audit correlation. |
| Name | Type | Description |
|---|---|---|
{ ok: true, principal } |
success |
A resolved Principal; optional responseHeaders and setCookies the service flushes to the client. |
{ ok: false, reason } |
rejection |
A typed rejection reason — fail-loud, never a silent anonymous principal. |
The service surface
The plugin's service is named auth-api and is built with @netscript/service's oRPC builder
(createService(...).withRPC()), not raw Hono. It mounts a public REST surface at
/api/v1/auth/* and the equivalent oRPC surface at /api/rpc/v1/auth/*, plus standard
/health/live and /health/ready probes.
| Name | Type | Description |
|---|---|---|
signin |
POST |
Begins the interactive sign-in. Requires backend.interactive; on WorkOS / better-auth it returns AUTH_PROVIDER_ERROR (502) because those backends are non-interactive. |
callback |
POST |
Completes the OAuth/OIDC redirect, mints the session, sets the session cookie. Interactive-only — same non-interactive caveat as signin. |
signout |
POST |
Ends the caller's own session and clears the cookie. Requires a credential (session cookie or bearer), else 401. sessionId selects another session of the same subject; everywhere: true revokes every session of the subject. |
revokeSession |
POST |
Operator revocation of any session, at /sessions/revoke. Requires the auth:sessions:revoke scope (403 without it). kv-oauth returns { revoked: false } for a definite unknown id; better-auth and WorkOS return AUTH_PROVIDER_ERROR (502), since revocation by id is unsupported. |
session |
GET |
Resolves the current AuthSession from the cookie (active | expired | revoked), refreshing on read when policy allows. |
me |
GET |
Returns { authenticated: true, user, session } when a valid active session exists, or { authenticated: false } (HTTP 200) when there is none. |
Signout acts only for the caller
signout and revokeSession are the only guarded procedures. The service authenticates them
in-process against the active backend: a bearer credential resolves through the session store,
otherwise the __Host-ns_session cookie does. Every other procedure stays public introspection.
- Ownership. A
sessionIdmust name a session whose subject is the caller's subject. A session that does not exist and a session that belongs to someone else get the same401 UNAUTHORIZEDbody, so signout cannot be used to probe for valid session ids. - Nothing on refusal. A refused signout revokes nothing, emits no
auth.session.revokedevent, and records nosuccessoutcome. - Global logout.
everywhere: truerevokes every session of the caller's subject and no other subject's, throughAuthSessionStorePort.revokeSubjectSessions. That call does a bounded amount of work and never walks the subject's sessions.kv-oauthrecords one per-subject revocation instant: any session issued at or before it stops resolving as active. That includes sessions stored before this release, so no migration is needed.better-authdelegates to its ownapi.revokeSessionsthrough the caller's credential. NetScript reads every better-auth session from the server-side store (disableCookieCache), so a cachedsession_datacookie stops authenticating through NetScript on the next request; the better-auth page describes the residual window on better-auth's own endpoints.workosatomically persists a local subject fence and a durable cleanup job. Its service-owned worker lists at most 50 upstream sessions per delivery and revokes only the caller's sessions created at or before logout. Local authentication refuses the subject immediately, including still-unexpired access tokens. During cleanup it also refuses newly created sessions of that subject; they become usable when cleanup succeeds. Permanent session tombstones prevent rotated tokens from reviving an old session. Other subjects remain usable. Failed pages retry automatically until recovery, with backoff capped at 60 seconds. Storage failures are supervised, and shutdown signals drain the worker. Session tombstones expire only after upstream success plus the longest configured token lifetime and clock-skew padding. See the WorkOS reference for standalone composition. - Operators. Revoking somebody else's session is the separate
revokeSessionprocedure, gated by theauth:sessions:revokescope on an authenticated backend session. The auth service's guard currently accepts backend session credentials only; internal service credentials are not composed into this guard. The CLI'snetscript plugin auth session listandsession revokeboth refuse to run without a credential. Both read it fromNETSCRIPT_AUTH_TOKEN. - Revocation by id. The kv-oauth backend supports operator revocation and same-subject sibling
selectors. A definite missing kv-oauth session returns
{ revoked: false }from the operator route. The better-auth and WorkOS adapters cannot revoke by id: they returnAUTH_PROVIDER_ERROR(502), never a successful "not found" result. For signout, the request credential can prove ownership of the caller's own selected session, which then gets the same provider error for single-session revocation. These adapters cannot prove ownership of a sibling by id alone, so sibling, foreign and unknown selectors receive the same401ownership refusal. On better-auth, omit the sibling selector and useeverywhere: truefor supported subject-wide logout.
// Browser: the session cookie authenticates the call and selects the session to end.
await fetch(`${AUTH}/signout`, { method: 'POST', credentials: 'include' });
// Bearer client: end every session of the caller's subject.
await fetch(`${AUTH}/signout`, {
method: 'POST',
headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' },
body: JSON.stringify({ everywhere: true }),
});
The three backends
All three implement the same AuthBackendPort, so the auth-api contract is identical across
them. They differ in one decisive capability: whether they expose an interactive
sign-in/callback flow. Each ships a single factory that returns an AuthBackendPort.
| Name | Type | Description |
|---|---|---|
@netscript/auth-kv-oauth |
createKvOAuthBackend(options) |
Interactive (default). Full OAuth/OIDC redirect flow, Deno KV session store, real createSession / refreshSession / revokeSession, refresh-on-read (refreshMode / refreshSkewMs), opaque session token, __Host-ns_session cookie, AES-256-GCM token-at-rest. Needs a provider preset or defineOAuthProvider config. |
@netscript/auth-workos |
createWorkosBackend({ workos, cookiePassword }) |
Non-interactive. WorkOS AuthKit sealed sessions, stateless verification. Needs a WorkOS SDK client and a cookie password. signin / callback return AUTH_PROVIDER_ERROR; session mutations throw AuthBackendOperationUnsupportedError. |
@netscript/auth-better-auth |
createBetterAuthBackend({ auth, sessionTokenSecret }) |
Non-interactive. Validates externally-issued sessions via auth.api.getSession over a Prisma store (createNetscriptBetterAuth wires the better-auth Prisma adapter). Needs a better-auth instance and a token secret. |
How auth integrates with services
The auth plugin (this page) signs human users in and resolves their sessions. A
NetScript service gates its own routes with the separate, provider-agnostic
@netscript/service/auth seam — .withAuthn() resolves a Principal and .withAuthz() makes
an authorization decision from it. Both layers speak the same Principal type, so they compose:
the auth plugin establishes identity, and a service's .withAuthn({ authenticator }) can trust a
backend's authenticator to turn a request into that Principal. By default a service protects
/api and leaves /health anonymous. The full builder seam — createStaticCredentialAuthenticator,
createTrustedHeaderAuthenticator, createScopeAuthorizer, and the preset
defineService(router, { auth: { authn, authz } }) form — is documented on the services hub.
Verify sessions from another plugin's service
Another plugin's service does not need the auth backend in order to trust its sessions.
createAuthServiceAuthenticator from @netscript/plugin-auth/authenticator returns an
AuthenticatorPort that verifies each request's bearer session by calling the auth service's
typed session procedure. The guarded service therefore holds no backend instance, no KV handle,
and no provider secret. A plugin service declares that guard in its required auth posture:
// plugins/billing/services/src/main.ts
import { createPluginService } from '@netscript/plugin/service';
import { mountPluginContract } from '@netscript/plugin/contract-base';
import { createAuthServiceAuthenticator } from '@netscript/plugin-auth/authenticator';
import { createContractAuthorizer } from '@netscript/service/auth';
import { billingContractDefinition } from '@netscript/plugin-billing-core/contracts/v1';
import { billingContractMount, billingRouter } from './handlers.ts';
export const billingService = createPluginService(billingRouter, {
name: 'billing',
auth: {
authn: {
authenticator: createAuthServiceAuthenticator({ serviceName: 'auth', timeoutMs: 10_000 }),
},
authz: {
authorizer: createContractAuthorizer(
mountPluginContract(billingContractDefinition, billingContractMount),
),
},
},
});
A missing or rejected session returns 401, an insufficient scope returns 403, and an
unreachable auth service returns 503. A revoked session is rejected on its next request, because
the authenticator keeps no principal between requests. netscript plugin new <name> generates
this form. Background callers (workers, sagas, triggers) run under a service identity, not an app
session. A guarded API they call over HTTP must accept a service credential, for example through
createStaticCredentialAuthenticator. The
add-authentication how-to
walks through the posture choices.
Database & runtime events
The plugin contributes a package-provided auth.prisma schema with four
better-auth-shaped models — AuthUser → auth_users, AuthSession → auth_sessions,
AuthAccount → auth_accounts, AuthVerification → auth_verifications. These tables back the
better-auth adapter; kv-oauth keeps its sessions in Deno KV, and WorkOS is stateless. As with
every plugin schema, you bring the tables to life by running the database workflow after Aspire
is up — see Database migrations.
The plugin also emits five durable auth.* runtime events through the durable-streams runtime —
the AUTH_STREAM_EVENT_TYPES: auth.signin.started, auth.signin.failed,
auth.token.refreshed, auth.session.revoked, and auth.oidc.completed. The session projection
is described by the authStreamSchema entity stream. For the streams runtime itself see
Endpoints & ports
| Name | Type | Description |
|---|---|---|
dynamic |
port |
auth-api default port fallback (AUTH_API_DEFAULT_PORT is 8094). Note: your scaffold's ports will differ as they are allocated from the high-range (49152+) at scaffold time. |
auth-api |
service name |
AUTH_API_SERVICE_NAME — the service contribution the auth plugin (AUTH_PLUGIN_ID 'auth') adds. |
/api/v1/auth/_ |
REST |
REST surface: signin, callback, signout, sessions/revoke, session, me. Only signout and sessions/revoke require a credential. |
/api/rpc/v1/auth/_ |
oRPC |
The oRPC surface for the same six operations, for typed NetScript clients. |
/health/live |
HTTP |
Liveness probe; /health/ready for readiness. OpenAPI + docs served via .withOpenAPI()/.withDocs(). |
NETSCRIPT_AUTH_BACKEND |
env |
Selects the single active backend: kv-oauth (default) | workos | better-auth. |
Production notes
Reference
The auth runtime is a @netscript/service; the auth plugin, core contract package, and backend
adapters now have dedicated reference pages.