Operational session lifecycles
Authenticating a request is only half the battle. A working production auth flow requires a complete HTTP lifecycle: mounting public endpoints, redirecting to providers, exchanging callback codes, checking safe return URLs, and forwarding session-refresh cookies without dropping a single header.
This page covers the operational lifecycles for NetScript's three official authentication adapters. Use this guide to wire sign-in redirects, callbacks, and token validation into your services and meta-framework entry points.
Sealed-cookie vs. bearer-token paths
Before implementing, choose the session delivery path that fits your application architecture:
| Path | Mechanism | Ideal for | Adapters |
|---|---|---|---|
| Sealed-Cookie | The browser automatically sends the session cookie (__Host-ns_session or provider cookie) with each request. The server verifies it statelessly or against Deno KV. |
Web frontends (Fresh, server-rendered pages), traditional monolithic apps. | auth-kv-oauth, auth-better-auth, auth-workos (AuthKit) |
| Bearer-Token | The client (mobile app, SPA, or backend service) explicitly injects a JWT access token in the Authorization: Bearer <token> header. The server verifies it via JWKS. |
Stateless REST/oRPC APIs, mobile backends, machine-to-machine calls. | auth-workos (Bearer), auth-better-auth (Bearer) |
1. Better Auth: mounting and header forwarding
The better-auth adapter (@netscript/auth-better-auth) delegates the database tables to Prisma but leaves routing and session verification to NetScript.
The mounting lifecycle
better-auth exposes a fetch-compatible auth.handler(request) to drive its internal database and provider operations (magic links, user settings, credentials verification). You must mount this handler under /api/auth/** in your web server.
Refreshed session forwarding (Critical)
When verifying a session on a protected route via backend.authenticate(authnReq), better-auth may rotate or refresh the session. When this happens, AuthnResult returns new headers in responseHeaders and setCookies. You must append these headers to the HTTP response sent back to the browser. Dropping them will silently log the user out on the next request.
import { Hono } from "npm:hono@^4";
import { createBetterAuthBackend, createNetscriptBetterAuth } from "@netscript/auth-better-auth";
import type { AuthnRequest } from "@netscript/service/auth";
// 1. Initialize Prisma and NetScript Better Auth
const prisma = {}; // Your Prisma client instance
const auth = createNetscriptBetterAuth({
prisma,
provider: "postgresql",
secret: Deno.env.get("BETTER_AUTH_SECRET")!,
});
// 2. Initialize the NetScript Better Auth Adapter
const backend = createBetterAuthBackend({
auth,
sessionTokenSecret: Deno.env.get("BETTER_AUTH_SECRET")!,
});
const app: Hono = new Hono();
// Helper to adapt Hono's Request into NetScript's AuthnRequest
function toAuthnRequest(req: Request): AuthnRequest {
const url = new URL(req.url);
return {
header: (name) => req.headers.get(name) ?? undefined,
headers: () => req.headers,
cookie: (name) => {
const cookieHeader = req.headers.get("cookie") ?? "";
const match = cookieHeader.match(new RegExp(`(^|;)\\s*${name}\\s*=\\s*([^;]+)`));
return match ? decodeURIComponent(match[2]) : undefined;
},
method: req.method,
path: url.pathname,
};
}
// 3. Mount the public better-auth handler at /api/auth/*
app.all("/api/auth/*", async (c) => {
const response = await auth.handler(c.req.raw);
return response;
});
// 4. Authenticate a protected request and forward headers/cookies
app.get("/api/protected", async (c) => {
const authnReq = toAuthnRequest(c.req.raw);
const result = await backend.authenticate(authnReq);
if (!result.ok) {
return c.json({ error: result.reason }, 401);
}
// FORWARD HEADERS: Refreshed session tokens are written onto headers
if (result.responseHeaders) {
for (const [key, value] of Object.entries(result.responseHeaders)) {
c.header(key, value);
}
}
// FORWARD COOKIES: Rotation cookies must reach the browser
if (result.setCookies) {
for (const cookie of result.setCookies) {
c.header("set-cookie", cookie, { append: true });
}
}
return c.json({
message: "Access granted",
principal: result.principal,
});
});
2. KV OAuth: interactive redirect and callback lifecycles
The KV OAuth adapter (@netscript/auth-kv-oauth) is NetScript's default interactive authentication backend. It drives the browser through the full OAuth2/OIDC code exchange, storing active session details in Deno KV.
The four-stage lifecycle
- Sign-In Redirect:
backend.signIngenerates a transaction code verifier (PKCE), code challenge, state, and transaction ID. It stores this state in KV, sets a temporary transaction cookie, and returns a302redirect to the Identity Provider (IdP). - Callback Exchange: The IdP redirects the user back to
/auth/callbackwith acodeandstate.backend.handleCallbackverifies the state against the transaction cookie, exchanges the authorization code for tokens, resolves user claims, mints an active session in Deno KV, and returns a redirect to the targetreturnToURL while writing the__Host-ns_sessioncookie. - Session Verification: Subsequent requests carry the session cookie.
backend.authenticateverifies the session ID against Deno KV, automatically handles refresh-on-read, and returns the mappedPrincipal. - Sign-Out:
backend.signOutdeletes the session from Deno KV and returns a response that clears the__Host-ns_sessioncookie.
Sign-in redirect and Location-header propagation
When initiating a sign-in flow via backend.signIn(request, options), the adapter returns an HTTP 302 Found redirect response. This response is critical for:
- Location-Header Propagation: The framework or hosting server must propagate the
Locationheader to redirect the user's browser to the Identity Provider's authorize endpoint. - Transaction Cookie Propagation: The response contains
Set-Cookieheaders for temporary transaction state (e.g., PKCE verifiers and state tokens) which must be sent to the browser.
Callback exchange and cookie/Location propagation
Upon successful authorization, the Identity Provider redirects the user back to the registered /auth/callback endpoint with a temporary authorization code and state.
Calling backend.handleCallback(request) performs the secure token exchange and returns a redirect response to the validated returnTo target. The server must propagate:
- Location Redirect: The
Locationheader guiding the user back to the application (e.g.,/dashboard). - Session Cookie: The
Set-Cookieheader establishing the__Host-ns_sessioncookie (and clearing the temporary transaction cookie).
Sign-out, session teardown, and cookie clearing
To log a user out of the application:
- Endpoint: Mount a dedicated sign-out route (e.g.,
GET /auth/signout). - Session Teardown: Invoke
backend.signOut(request)inside this endpoint. This deletes the active session record from Deno KV, immediately invalidating subsequent request authentications using this session ID. - Cookie Clearing: The returned response contains a
Set-Cookieheader setting__Host-ns_sessionto expire immediately (e.g.,Max-Age=0), along with aLocationheader redirecting the user back to a safe public route (like the landing or login page). Both must be propagated to the client.
Return target and identity normalization
allowedReturnTo: To prevent open-redirect vulnerabilities, the backend restricts post-login redirects. It validates the target against a preset array of URL prefixes or a custom evaluation function. If validation fails, it throws areturn_to_not_allowederror.normalizePrincipal: Customizes how user claims and tokens map to a NetScriptPrincipal(subject, scopes, and roles).
import { Hono } from "npm:hono@^4";
import { createKvOAuthBackend, providers } from "@netscript/auth-kv-oauth";
import type { AuthnRequest } from "@netscript/service/auth";
// 1. Compose the KV OAuth Backend with allowedReturnTo & identity normalization
const backend = await createKvOAuthBackend({
provider: providers.github({
clientId: Deno.env.get("NETSCRIPT_AUTH_CLIENT_ID")!,
clientSecret: Deno.env.get("NETSCRIPT_AUTH_CLIENT_SECRET")!,
redirectUri: "http://localhost:8000/auth/callback",
}),
allowedReturnTo: [
"http://localhost:8000/dashboard",
"http://localhost:8000/profile",
],
defaultReturnTo: "http://localhost:8000/dashboard",
// Custom identity normalization mapping
normalizePrincipal: (ctx) => {
const email = (ctx.claims.email as string) ?? "";
const isCompanyEmail = email.endsWith("@mycompany.com");
return {
subject: ctx.claims.sub as string,
scopes: ctx.tokenSet.scope?.split(/\s+/) ?? ["read"],
roles: isCompanyEmail ? ["admin", "user"] : ["user"],
scheme: "custom",
claims: {
email,
providerId: ctx.provider.id,
sessionId: ctx.sessionId,
},
};
},
});
const app: Hono = new Hono();
function toAuthnRequest(req: Request): AuthnRequest {
const url = new URL(req.url);
return {
header: (name) => req.headers.get(name) ?? undefined,
headers: () => req.headers,
cookie: (name) => {
const cookieHeader = req.headers.get("cookie") ?? "";
const match = cookieHeader.match(new RegExp(`(^|;)\\s*${name}\\s*=\\s*([^;]+)`));
return match ? decodeURIComponent(match[2]) : undefined;
},
method: req.method,
path: url.pathname,
};
}
// 2. Sign-in: begins flow, redirects browser to GitHub via Location header propagation
app.get("/auth/signin", async (c) => {
const returnTo = c.req.query("returnTo") ?? undefined;
const response = await backend.signIn(c.req.raw, { returnTo });
// Propagates the redirect Location and transaction cookies to the browser
return response;
});
// 3. Callback: exchanges code, creates session, and redirects to returnTo via Location/Cookie propagation
app.get("/auth/callback", async (c) => {
try {
const { response, sessionId, principal } = await backend.handleCallback(c.req.raw);
console.log(`Session ${sessionId} established for user ${principal.subject}`);
// Propagates the redirect Location header and set-cookies (session cookie)
return response;
} catch (error) {
return c.text(`Authentication failed: ${error}`, 400);
}
});
// 4. Session Lookup: verify session cookie on subsequent requests
app.get("/auth/me", async (c) => {
const authnReq = toAuthnRequest(c.req.raw);
const result = await backend.authenticate(authnReq);
if (!result.ok) {
return c.json({ authenticated: false, reason: result.reason }, 401);
}
// Forward refreshed session cookies if issued by the backend on-read
if (result.setCookies) {
for (const cookie of result.setCookies) {
c.header("set-cookie", cookie, { append: true });
}
}
return c.json({
authenticated: true,
principal: result.principal,
});
});
// 5. Sign-out: deletes session from KV (teardown), clears browser cookie, and redirects
app.get("/auth/signout", async (c) => {
const response = await backend.signOut(c.req.raw);
// Propagates the cookie-clearing header and redirect Location
return response;
});
3. WorkOS: stateless bearer access-token verification
The WorkOS adapter (@netscript/auth-workos) provides a dedicated createWorkosAccessTokenAuthenticator to verify stateless Bearer JWTs issued by WorkOS. This is ideal when the client handles sign-in through WorkOS AuthKit and NetScript only serves as a protected API gateway.
How it works
- JWKS Verification: The authenticator reads the
Authorization: Bearer <token>header and fetches the WorkOS public keys (JWKS) from the key-set URL (https://api.workos.com/sso/jwks/<clientId>) to verify the signature. Keys are cached locally, and rotation is handled automatically (the JWKS client refreshes the cache when signature verification fails on an unknown key). - Audience & Issuer Claims: It asserts that the token's audience (
aud) matches the WorkOSclientIdand that the issuer (iss) matches your WorkOS domain. - Principal Mapping: It maps standard WorkOS JWT claims (
sub,org_id,role,roles,permissions) to the neutral NetScriptPrincipal(scheme"custom"). - Named Failure Outcomes: Rejections are explicitly returned via
ok: falsewith specific codes rather than thrown, allowing developers to handle missing versus malformed tokens differently.
import { Hono } from "npm:hono@^4";
import { createWorkosAccessTokenAuthenticator } from "@netscript/auth-workos";
import type { AuthnRequest } from "@netscript/service/auth";
// 1. Create a stateless WorkOS bearer authenticator
const authenticator = createWorkosAccessTokenAuthenticator({
clientId: "client_01H...", // Your WorkOS Client ID (asserted as expected audience)
// JWKS Key-Set Configuration:
// WorkOS hosts public keys at https://api.workos.com/sso/jwks/<clientId>.
// The authenticator fetches and caches keys from this URL. WorkOS handles key
// rotation automatically; the underlying jose library refreshes the key-set
// cache on signature verification failures.
jwksUrl: "https://api.workos.com/sso/jwks/client_01H...",
issuer: "https://api.workos.com", // Expected JWT issuer
});
const app: Hono = new Hono();
function toAuthnRequest(req: Request): AuthnRequest {
const url = new URL(req.url);
return {
header: (name) => req.headers.get(name) ?? undefined,
headers: () => req.headers,
cookie: (name) => {
const cookieHeader = req.headers.get("cookie") ?? "";
const match = cookieHeader.match(new RegExp(`(^|;)\\s*${name}\\s*=\\s*([^;]+)`));
return match ? decodeURIComponent(match[2]) : undefined;
},
method: req.method,
path: url.pathname,
};
}
// 2. Protect routes with the authenticator
app.get("/api/protected", async (c) => {
const authnReq = toAuthnRequest(c.req.raw);
const result = await authenticator.authenticate(authnReq);
if (!result.ok) {
// 3. Handle named failure outcomes explicitly
if (result.reason === "workos_bearer_token_missing") {
return c.json({ error: "Authorization header with Bearer token is missing" }, 401);
}
// E.g. token expired, invalid signature, or wrong audience
return c.json({ error: `Authentication failed: ${result.reason}` }, 401);
}
// 4. Principal claims are mapped to NetScript principal structure
const principal = result.principal;
return c.json({
message: "Access granted",
userId: principal.subject,
scopes: principal.scopes,
roles: principal.roles,
organizationId: principal.claims.organizationId,
});
});
Where to go next
Authentication — Overview of endpoints, DB tables, and environment variables.
Auth model — Deep dive into the pure-backend seam philosophy.
- Reference: auth-better-auth — Full API reference for better-auth.
- Reference: auth-kv-oauth — Full API reference for kv-oauth.
- Reference: auth-workos — Full API reference for WorkOS.