Curiosity

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.

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.
© 2026 Curiosity. All rights reserved.