Security Best Practices

Endpoints run inside the workspace process with full access to the graph, the search index, the AI runtime, and (depending on tokens) other endpoints. The defaults are safe, but a handful of habits keep them safe in practice.

1. Default to Restricted

Only mark an endpoint Unrestricted if it's deliberately public (a login form's reverse-CAPTCHA, an unauthenticated health check). For anything that touches user data, leave it Restricted and require a token or session.

2. Filter by caller identity

The single most important rule for endpoints that return graph data:

// YES — applies the caller's ReBAC permissions.
var query = await Graph.CreateSearchAsUserAsync(request, CurrentUser, CancellationToken);

// NO — bypasses permissions; everyone sees everything.
var query = await Graph.CreateSearchAsync(request, CancellationToken);

CreateSearchAsync exists for admin scripts, scheduled tasks, and debugging — not for endpoints that run on behalf of users. The permission-aware search tutorial is the deep dive.

3. Handle the token-caller case explicitly

When an endpoint is called via an endpoint token instead of a user session, CurrentUser is default. Decide what that means up front:

if (CurrentUser == default)
    return Forbid("This endpoint requires a user session.");

The "refuse" branch is the safer default for endpoints that touch restricted data. If the endpoint is designed for service-to-service use, document that explicitly and gate the body of the endpoint on a different signal (request signature, IP allow-list, etc.).

4. Validate input

Body.FromJson<T>() deserializes — it doesn't validate. Always check for the obvious failure modes:

var req = Body.FromJson<MyRequest>();
if (req is null)                            return BadRequest("Missing body.");
if (string.IsNullOrWhiteSpace(req.Query))   return BadRequest("Query is required.");
if (req.Limit is <= 0 or > 100)             return BadRequest("Limit must be 1..100.");

Treat strings used inside Q().StartAt(type, key) as a graph-side key lookup, not raw SQL — but still keep them bounded so a caller can't probe arbitrary keys at scale.

5. Avoid RunAsAdmin for user endpoints

Run as admin flips the endpoint into admin scope — no ACL filtering, no per-user audit trail. It's appropriate for internal automation (scheduled tasks calling another endpoint to do a write) but never for endpoints called by end users.

6. Don't leak details in error responses

try
{
    return Ok(await DoWorkAsync());
}
catch (Exception ex)
{
    Logger.LogError(ex, "my-endpoint failed for user {UID}", CurrentUser);
    return StatusCode(500, new { error = "An internal error occurred." });
}

Logs go to your aggregator (which you control); responses go to the caller (who you may not). Keep stack traces, query text, and internal IDs on the server side.

7. Audit sensitive endpoints

If the endpoint reads or modifies restricted data, write an audit node:

var audit = Graph.AddOrUpdate(new EndpointAuditEntry
{
    Id        = Guid.NewGuid().ToString("n"),
    UserUID   = CurrentUser,
    Endpoint  = "my-endpoint",
    Timestamp = DateTimeOffset.UtcNow,
    Payload   = req.ToJson(),
});
await Graph.CommitPendingAsync();

Audit nodes should be append-only: protect the type with RestrictAccessToTeam(auditNode, auditTeam) so end users can't read or mutate them.

8. Scope tokens narrowly

When you mint an endpoint token, scope it to the specific endpoints the caller needs. The token system supports per-path scoping — use it. See Endpoint tokens.

9. Treat client-supplied UIDs as data, not as an access grant

A common bulk-lookup shape accepts a caller-supplied list of UIDs — "nodes to render," "already visible on screen," "expand these" — and uses that list directly to decide what related data (edges, neighbors, aggregates) gets included in the response. If the endpoint only access-checks the node it was explicitly asked to look up, and then serializes results against the raw caller-supplied list, a caller can pad that list with UIDs it has no access to and infer their existence, type, or connections from the response, without ever passing a direct access check on them.

// NO — visibleUIDs is whatever the caller sent, unchecked; edges to a
// restricted UID leak that the (accessible) source connects to it.
var visibleUIDs = new HashSet<UID128>(request.UIDs);
visibleUIDs.UnionWith(request.AlreadyVisibleUIDs ?? []);

// YES — re-check every caller-supplied UID before treating it as visible.
var visibleUIDs = new HashSet<UID128>();
foreach (var uid in request.UIDs.Concat(request.AlreadyVisibleUIDs ?? []))
{
    if (Graph.CanAccess(CurrentUser, uid)) { visibleUIDs.Add(uid); }
}

This applies to any endpoint that accepts a UID list from the client and later uses it as a set of "known-visible" targets — not just the top-level node(s) the endpoint explicitly fetches.

Checklist

  • Endpoint is Restricted unless explicitly public.
  • Every read path uses CreateSearchAsUserAsync.
  • CurrentUser == default is handled explicitly.
  • Input validated with explicit BadRequest returns.
  • No Run as admin for user-facing endpoints.
  • Errors logged server-side; responses are generic.
  • Audit node written for sensitive paths.
  • Token scopes follow least-privilege.
  • Caller-supplied UID lists are re-checked with Graph.CanAccess, not trusted as pre-filtered.
© 2026 Curiosity. All rights reserved.