Skip to content

Securing Routes

wiki edited this page Sep 4, 2026 · 1 revision

Securing routes

A route declares its requirements by implementing interfaces. A route that implements none is public.

type SecuredRoute interface {
	RequiredSchemes() []string // empty or nil → public
}

Declaring

type AdminRoute struct{ rxroute.Route }

func (r *AdminRoute) RequiredSchemes() []string { return []string{"jwt"} }

app.RegisterRoute(&AdminRoute{
	Route: rxroute.New("DELETE", "/users/{id}", deleteUser),
})

⚠ With a pointer receiver, the route must be registered as a pointer or the type assertion fails and the route is treated as public. This is the single easiest way to accidentally publish an endpoint.

A small helper keeps it terse:

func secured(rt rxroute.Route, schemes ...string) rxroute.Route {
	return &securedRoute{Route: rt, schemes: schemes}
}

type securedRoute struct {
	rxroute.Route
	schemes []string
}

func (r *securedRoute) RequiredSchemes() []string { return r.schemes }

app.RegisterRoute(secured(rxroute.New("DELETE", "/users/{id}", deleteUser), "jwt"))

The full route contract

Interface Method Enforced
SecuredRoute RequiredSchemes() []string every named scheme must authenticate
RoleGuardedRoute RequiredRoles() map[string][]string all listed roles must be present
ScopedSecuredRoute RequiredScopes() map[string][]string all listed scopes must be present
CSRFExemptRoute CSRFExempt() bool opts out of CSRF
func (r *AdminRoute) RequiredSchemes() []string { return []string{"jwt"} }

func (r *AdminRoute) RequiredRoles() map[string][]string {
	return map[string][]string{"jwt": {"admin", "user:write"}} // ALL required
}

func (r *AdminRoute) RequiredScopes() map[string][]string {
	return map[string][]string{"jwt": {"users.write"}}
}

The map key is a scheme name that must appear in RequiredSchemes. Roles declared for a scheme the route does not require are a startup error.

Where the middleware runs

At rextension.PriorityAuth (400) — after rate limiting, before CSRF (450) and validation (500). So a flood of unauthenticated requests is shed before it costs a token verification, and a body is never parsed for a request that was going to be refused.

The middleware attaches only to routes that declare requirements.

Reading the principal

func deleteUser(ctx rxroute.Context) {
	user, ok := security.GetPrincipalAs[*User](ctx.Request())
	if !ok {
		// unreachable on a secured route, but do not assume
		rextension.WriteProblem(ctx.ResponseWriter(), ctx.Request(),
			401, rextension.ProblemUnauthorized, "credentials were not accepted")
		return
	}
	…
}
Helper
GetPrincipal(r) the primary principal, as interface{}
GetPrincipalAs[T](r) the same, type-asserted
GetPrincipalForScheme(r, name) the principal a specific scheme established
GetPrincipalForSchemeAs[T](r, name) the same, type-asserted
GetAllPrincipals(r) every principal, keyed by scheme name (a copy)
GetSchemeName(r) which scheme authenticated

The principal is whatever your validator returned. Make it a concrete type you control, so GetPrincipalAs[*User] is meaningful.

Responses

Situation Status Slug
no credential, or it did not validate 401 + WWW-Authenticate unauthorized
authenticated, but a role or scope is missing 403 forbidden

Both are problem documents with a generic detail. The reason goes to the log, never to the client — a validator's error text routinely names another account, an internal host, or the shape of the authorization model.

Configure the logger, or the specifics are simply lost and the redaction rule becomes unmaintainable in practice:

security.MiddlewareConfig{Logger: r.Logger()}

Public routes

Do not implement SecuredRoute — or return nil:

func (r *HealthRoute) RequiredSchemes() []string { return nil }

Both mean public. Prefer not implementing it: an explicit nil reads as an oversight to the next person.

Composing it yourself

The extension registers the middleware itself, per route, at rextension.PriorityAuth. If you are composing a chain by hand — outside Rex, or in a test — security.SecurityMiddleware(cfg) is the plain net/http form:

mw := security.SecurityMiddleware(security.MiddlewareConfig{
	SchemeRegistry: registry,
	Logger:         logger,
})
handler = mw(handler)

It reads the matched route from the request context, so it needs to run inside the Rex router to see one.

Clone this wiki locally