Scopes and Permissions
OAuth scope is the client's delegation ceiling, enforceable at the API edge. FGA is the per-user, per-resource authorization authority. Effective permission is the intersection.
SqlOS separates two questions that OAuth deployments often blur:
scope claim, and the consent screen.These are complementary, not competing: scope bounds the client's ceiling, FGA decides the user's access, and a request is allowed only when both agree. The glossary makes the same distinction.
User access tokens carry the granted scope as a scope claim (alongside sub, sid, client_id, and the rest — see Token Validation). The claim is omitted for sessions created before scope tracking and for direct non-OAuth logins, where the grant is unknown; it is never fabricated.
Enforcement at the API edge is opt-in in the handler. Inspect GetSqlOSValidatedToken()?.Scope after a declared Api or Mcp surface has validated the token:
var token = http.GetSqlOSValidatedToken();
if (token is null)
return Results.Unauthorized();
var granted = token.Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
?? [];
if (!granted.Contains("todos.read"))
return Results.Forbid();A token without a scope claim fails closed. Do not assume the widest grant.
When does this matter? With OpenID Provider mode, third-party applications hold delegated user tokens. The consent screen's promise — "this application will be able to: sign you in, see your name" — is a claim about the client's ceiling, and a handler scope check is how an API makes that ceiling real: a client granted only openid profile cannot reach an endpoint that requires todos.write, no matter what its user could do directly. For APIs serving only first-party clients, requiring scopes adds little — audience binding plus FGA already carries the load.
A scope check authorizes the application, not the user. todos.write in a token means the client was granted permission to ask for writes — it says nothing about whether this user may write this row, and it is frozen at authorization time, blind to every grant or role change since. FGA sees the subject, the resource, and the current grants on every request, and SqlOS validation is session-aware, so revocation and offboarding bite before token expiry.
So the resource-server shape is: bind the audience on a declared surface, optionally check the client's scope ceiling in the handler, then let FGA decide — the same flow as the EF authorization quickstart:
api.MapGet("/projects", async (
HttpContext http,
AppDbContext db,
ISqlOSFgaAuthService authorization,
CancellationToken cancellationToken) =>
{
var subjectId = http.GetSqlOSValidatedToken()?.UserId;
if (string.IsNullOrWhiteSpace(subjectId))
{
return Results.Unauthorized();
}
var filter = await authorization.BuildFilterAsync<Project>(
subjectId,
"project.read");
var projects = await db.Projects
.AsNoTracking()
.Where(filter)
.ToListAsync(cancellationToken);
return Results.Ok(projects);
});The audience check proves the token was minted for this API. A handler scope check (when you need one) proves the client's delegation covers the operation. The FGA filter decides which rows this user sees. Treat a scope check that stands in for the FGA decision as a bug: it authorizes the app while leaving the user unchecked.
This example is the user-token flow: UserId is deliberately null on a validated client-credentials service token. An endpoint that also accepts service tokens should branch on the token_kind claim and resolve the service-account subject from the principal's sub claim instead.
This applies with full force to MCP resource servers: a dynamically registered MCP client can request whatever scopes it likes, so requested scope is untrusted input. The granted scope claim is trustworthy — it survived the allowlist intersection — and a handler scope check plus audience binding plus per-operation FGA is the full pattern; see MCP OAuth.
Client-credentials tokens for machine clients carry scope and token_kind: "service". There is no interactive user behind them, so the granted scope set is the description of what the automation was admitted to do; a resource server handling service tokens should require both the scope relevant to the operation and the FGA decision for the service-account subject.
AllowedScopes (Clients); an empty allowlist is deny-all.openid mints an ID token, and profile / email decide which claims the UserInfo endpoint releases. Identity claims are deliberately not embedded in the ID token itself (OIDC Core §5.4 releases them from UserInfo in the code flow).