Express Auth User Guide

Configure and use Go-owned planned authentication for Express-style Goja HTTP routes.

Sections

Terminology & Glossary
📖 Documentation
Navigation
38 sectionsv0.1
📄 Express Auth User Guide — glaze help express-auth-user-guide
express-auth-user-guide

Express Auth User Guide

Configure and use Go-owned planned authentication for Express-style Goja HTTP routes.

Topichttpexpressauthsecuritygojajavascriptxgojagoja-repl

The express auth framework makes every normal route explicit about its security mode. JavaScript authors keep the familiar app.get, app.post, and app.patch names, but those helpers now return staged planned-route builders instead of accepting raw (req, res) handlers. Go owns the route plan, validates it at registration time, and enforces authentication, resource resolution, and authorization before JavaScript handler code runs.

Why planned auth routes exist

A route handler that manually reads cookies, headers, or session fields can forget a check or apply it inconsistently. Planned auth routes move that security decision into a Go-owned route plan. The JavaScript file declares intent, and the host decides whether the request has an actor, which resource is being touched, and whether the actor may perform the declared action.

The core rule is simple: a route cannot register a handler until it has declared whether it is public or protected.

const express = require("express")
const app = express.app()

app.get("/healthz")
  .public()
  .handle((_ctx, res) => res.json({ ok: true }))

app.get("/me")
  .auth(express.user().required())
  .allow("user.self.read")
  .handle((ctx, res) => res.json({ id: ctx.actor.id }))

If you skip .public() or .auth(...).allow(...), .handle(...) is not part of the current builder stage. If you pass a raw handler directly to app.get(pattern, handler), the module throws a migration error instead of silently registering an unplanned route.

Route builder stages

The builder stages encode the order required for a valid route. This makes invalid route declarations fail while the script loads, not on the first production request.

StageAvailable methodsWhat it means
RouteNeedsSecurity.name(), .public(), .auth()The route has a method and path but no security mode yet.
RouteNeedsPolicy.resource(), .allow()The route requires an actor and needs policy information before the handler can register.
RouteNeedsHandler.handle()The route has enough plan metadata to register.

Use the verb helpers for normal HTTP methods:

app.post("/projects")
  .auth(express.user().required())
  .allow("project.create")
  .handle((ctx, res) => res.status(201).json({ actor: ctx.actor.id }))

Use app.route(method, pattern) only when the method is dynamic or uncommon:

app.route("REPORT", "/reports/:id")
  .auth(express.user().required())
  .allow("report.read")
  .handle((ctx, res) => res.json({ id: ctx.params.id }))

Public routes

A public route is reachable without an actor, but it is still planned. Calling .public() records an explicit public security mode, which makes route lists and code reviews show that the exposure is intentional.

app.get("/assets-manifest")
  .public()
  .handle((_ctx, res) => {
    res.json({ version: "2026-06-12" })
  })

The handler receives (ctx, res). Public routes usually use ctx.params, ctx.body, or ctx.request for transport data. ctx.actor is null because no authenticated principal is required.

User-authenticated routes

A user-authenticated route declares that the host must produce an actor before the handler runs. The current MVP supports the express.user() builder and .required() user mode.

app.get("/me")
  .auth(express.user().required())
  .allow("user.self.read")
  .handle((ctx, res) => {
    res.json({
      id: ctx.actor.id,
      kind: ctx.actor.kind,
      tenantIds: ctx.actor.tenantIds || [],
    })
  })

The action string is application-owned. go-go-goja does not interpret user.self.read by itself. It passes the string to the host-provided Authorizer, together with the actor and any resolved resources.

Routes that need a recent MFA challenge can add .mfaFresh(duration) to the user spec. The duration is passed to the host authenticator as SecuritySpec.MFAFreshWithin; sessionauth.Manager enforces it against Session.MFAAt and rejects sessions with no MFA timestamp or a stale MFA timestamp.

app.post("/billing/payment-methods")
  .auth(express.user().required().mfaFresh("10m"))
  .csrf()
  .allow("billing.payment_method.update")
  .handle((ctx, res) => res.json({ actor: ctx.actor.id }))

Resource-bound routes

A resource-bound route declares how HTTP adapter values become typed inputs to the host resource resolver. The JavaScript route does not load the database row and does not decide whether the actor owns it. It only declares where the identifier and tenant boundary are located in the request.

app.patch("/orgs/:orgId/projects/:projectId")
  .auth(express.user().required())
  .resource(
    express.resource("project")
      .idFromParam("projectId")
      .tenantFromParam("orgId")
      .mustExist()
  )
  .allow("project.update")
  .handle((ctx, res) => {
    const project = ctx.resource("project")
    res.json({ updated: project.id, tenant: project.tenantId })
  })

idFromParam("projectId") means the host reads ctx.params.projectId and passes the resolved string as ResourceRequest.ID. tenantFromParam("orgId") passes ResourceRequest.TenantID. If either parameter is missing from the route pattern, registration fails before the server starts serving that route.

CSRF protection

CSRF protection is declared on the route plan and enforced by the Go host. Use .csrf() on unsafe browser/session routes that accept state-changing requests. The host CSRFProtector receives the HTTP request, planned request DTO, session, actor when present, and route plan before the JavaScript handler runs.

app.post("/contact")
  .public()
  .csrf()
  .audit("contact.submitted")
  .handle((ctx, res) => {
    res.json({ received: true })
  })

For unsafe methods such as POST, PUT, PATCH, and DELETE, a route with .csrf() fails closed when the host has no Auth.CSRF service or when the verifier returns an error. CSRF failures return 403 and the JavaScript handler is not invoked. Safe methods such as GET do not call the verifier even if the route plan carries a CSRF flag.

Audit events

Audit is also declared on the route plan. Use .audit(event) to ask the host to record structured security events around a planned request. The event name is application-owned and should describe the domain operation, such as project.updated or contact.submitted.

app.patch("/orgs/:orgId/projects/:projectId")
  .auth(express.user().required())
  .resource(express.resource("project").idFromParam("projectId").tenantFromParam("orgId"))
  .allow("project.update")
  .csrf()
  .audit("project.updated")
  .handle((ctx, res) => {
    const project = ctx.resource("project")
    res.json({ updated: project.id })
  })

The host AuditSink receives events for allowed, denied, completed, and failed outcomes. Audit events include the route event name, route name, method, pattern, action, actor, first resource, all resolved resources, status code when known, and failure reason when present. Audit sink errors are best-effort in the MVP: they are ignored so an unavailable audit backend does not turn an otherwise successful request into a 500.

Planned context

Planned handlers receive a context object instead of the old raw req object. The context exposes security state directly and keeps the original request DTO under ctx.request.

FieldMeaning
ctx.actorAuthenticated actor or null for public routes.
ctx.paramsRoute parameters such as { projectId: "p1" }.
ctx.bodyParsed request body.
ctx.requestFull request DTO: method, URL, query, headers, cookies, session, body, and raw body.
ctx.resourcesMap of resolved resource references by name.
ctx.resource(name)Convenience lookup that returns one resolved resource or null.
ctx.actionAction string declared by .allow(action).
ctx.routeNameOptional name declared by .name(name).

Common migrations are mechanical:

Old raw handler dataPlanned handler data
req.params.idctx.params.id
req.bodyctx.body
req.query.namectx.request.query.name
req.session.idctx.request.session.id
req.headers.authorizationctx.request.headers.authorization

Use ctx.actor and ctx.resource(...) for security-sensitive logic. Use ctx.request for transport details that are not themselves an authorization decision.

Host configuration

The JavaScript route only declares the plan. The embedding Go application provides auth services through gojahttp.HostOptions.Auth.

host := gojahttp.NewHost(gojahttp.HostOptions{
    Dev:             true,
    RejectRawRoutes: true,
    Auth: gojahttp.AuthOptions{
        Authenticator: myAuthenticator,
        Resources:     myResourceResolver,
        Authorizer:    myAuthorizer,
        CSRF:          myCSRFProtector,
        Audit:         myAuditSink,
    },
})

RejectRawRoutes is a production hardening option. It rejects any matched route that was registered without a RoutePlan, which keeps lower-level Host.Register callers from bypassing the planned auth framework.

The host interfaces are deliberately small:

type Authenticator interface {
    Authenticate(ctx context.Context, req *http.Request, session *SessionDTO, spec SecuritySpec) (*Actor, error)
}

type ResourceResolver interface {
    ResolveResource(ctx context.Context, req ResourceRequest) (*ResourceRef, error)
}

type Authorizer interface {
    Authorize(ctx context.Context, req AuthorizationRequest) (AuthorizationDecision, error)
}

Authenticator converts a request, session, bearer token, cookie, or upstream identity into an Actor. ResourceResolver converts declared resource IDs into ResourceRef values. Authorizer decides whether the actor may perform the declared action. CSRFProtector verifies route-declared CSRF protection on unsafe methods. AuditSink records route-declared security events.

Host auth package choices

The optional packages under pkg/gojahttp/auth/... provide reusable Go-side implementations for common host concerns. They are helpers, not a JavaScript auth framework: Express still only records route intent, and the host remains responsible for identity, sessions, resources, authorization, CSRF, and audit.

PackageUse it forProduction note
devauthLocal/demo users, login/logout/session endpoints, in-memory resources, authorization, CSRF, and audit.Demo-only; use it for examples and tests, not production auth.
sessionauthOpaque server-side app session cookies and CSRF token verification.Replace MemoryStore with durable storage for multi-instance deployments.
keycloakauthKeycloak/OIDC Authorization Code + PKCE login, callback, and logout handlers.Keep IdP tokens server-side; planned routes use the app session cookie.
appauthSmall app-owned users, tenants, memberships, resources, and deny-by-default authorization helpers.A starter shape; replace with domain services or a policy engine when needed.
auditNormalize route audit events into redacted records for logs or stores.Persist records if they matter for compliance or incident review.
capabilityHashed, expiring bearer-token flows such as org invites, email verification, password reset, or one-time downloads.Store only hashes and make single-use redemption atomic.

For development, start with devauth or the examples/xgoja/18-express-auth-host smoke. For generated runtime-package hosts, pkg/xgoja/hostauth provides the lazy ServiceFactoryKey seam used by examples/xgoja/21-generated-host-auth: the Go host injects session/store configuration with ConfigureServices, and the HTTP serve provider builds gojahttp.AuthOptions at command execution time. For production-shaped browser login, combine keycloakauth, sessionauth, appauth, and audit as shown in examples/xgoja/19-express-keycloak-auth-host.

Error behavior

Planned routes fail closed. Missing services are host configuration errors; missing or invalid credentials are request errors.

ConditionHTTP statusReason
Public route200 if handler succeedsNo auth services required.
Auth route with no authenticator500Host is misconfigured.
Authenticator returns ErrUnauthenticated or no actor401Request has no usable actor.
Authorizer denies403Actor exists but lacks permission.
Resource resolver returns ErrNotFound404Resource was not found or should not be disclosed.
JavaScript handler throws after auth succeeds500Handler failed after the security envelope passed.
Raw route matched while RejectRawRoutes is true500Host rejected an unplanned route before handler execution.
.csrf() verifier fails403CSRF failure blocks handler execution.
.audit(...) sink failsrequest status unchangedAudit is best-effort in the MVP.

In development mode, 500-class errors include more detail. In production mode, responses stay generic.

Query audit records and issue capabilities

Generated hosts can expose require("auth") to JavaScript routes. Use it after planned-route policy has authorized the request. The module is intentionally narrow: it provides bounded audit queries and capability-token builders, not raw auth database handles.

const auth = require("auth")

app.get("/orgs/:orgId/audit")
  .auth(express.user().required())
  .resource(express.resource("org").idFromParam("orgId").mustExist())
  .allow("audit.read")
  .audit("audit.records.read")
  .handle((ctx, res) => {
    const org = ctx.resource("org")
    const records = auth.audit.query()
      .tenantId(org.id)
      .limit(50)
      .run()
    res.json({ records, count: records.length })
  })

Capability routes follow the same rule: the route plan protects the issue operation, and the auth module performs the token operation. Public accept routes should catch expected capability errors and map reused single-use tokens to 409 Conflict rather than a generic server error. See goja-repl help auth-module-guide for the full JavaScript API.

Troubleshooting

ProblemCauseSolution
app.get(pattern, handler) was removedThe script still uses the old raw handler overload.Change public routes to app.get(pattern).public().handle(handler) and protected routes to an auth-aware chain.
.handle is not a functionThe route has not reached the handler stage.Add .public() or .auth(...).allow(...) before .handle(...).
.auth(...) expects value returned by express.user()A plain JavaScript object was passed to .auth(...).Use express.user().required() so Go can validate the auth spec identity.
.resource(...) expects value returned by express.resource(type)A plain JavaScript object was passed to .resource(...).Use express.resource("project").idFromParam("projectId").
references missing route parameterA resource builder references a param that is not in the path.Match the parameter name exactly, for example /projects/:projectId with .idFromParam("projectId").
Authenticated route returns 500The host is missing Authenticator, Authorizer, or required resource services.Configure gojahttp.HostOptions.Auth in the embedding Go application.
Raw route returns 500 with raw routes disabledThe host enabled RejectRawRoutes and matched a route registered through low-level Host.Register.Register the route through planned Express builders or Host.RegisterPlanned.
.csrf() route returns 500The route uses .csrf() on an unsafe method but the host has no Auth.CSRF service.Configure CSRFProtector in gojahttp.HostOptions.Auth.
Audit events are missingThe route does not call .audit(event) or the host has no Auth.Audit sink.Add .audit("domain.event") and configure an AuditSink.
Handler cannot find query or session fieldsPlanned handlers receive ctx, not raw req.Use ctx.request.query or ctx.request.session.

See Also

  • Express-style HTTP Module — The full module reference, including static mounts and response helpers.
  • Migrate Express Apps to Planned Auth Routes — Step-by-step migration tutorial for old app.get(path, handler) scripts.
  • Express Auth Examples — Dev-auth, generated-host, and Keycloak smoke-test guide for full host wiring.
  • examples/xgoja/18-express-auth-host — Runnable dev-auth example for public, current-user, and resource-bound planned routes.
  • examples/xgoja/21-generated-host-auth — Generated OIDC host example with JS-owned audit and capability routes plus fake-OIDC and Docker Compose Keycloak smokes.
  • examples/xgoja/20-express-hello-world — Minimal no-auth public-route host.

See also

  • xgoja help go-planned-auth-api — the Go route API that uses the same RoutePlan contract.
  • xgoja help express-auth-host-integration-guide — how a Go host composes OIDC, sessions, stores, and planned Express routes.
  • xgoja help auth-host-production-runbook — production deployment checklist for Keycloak-backed auth hosts.
  • xgoja help generated-auth-javascript-apis — generated-host require("auth") setup and example 21 route snippets.
  • goja-repl help auth-module-guide — JavaScript audit and capability builder reference.
  • goja-repl help express-auth-examples — runnable examples for dev auth, generated host auth, and Keycloak auth.