Token Validation
Validate access tokens for protected APIs.
Access tokens are RS256 JWTs. Validate on the server for each API call and always require the audience for the API that will process the token. SqlOS validation is session-aware; plain JWKS validation is not.
SqlOS validation is stateful: after signature, issuer, audience, and JWT lifetime checks, it loads the current session and requires that the session is not revoked or idle/absolutely expired. It also requires an active user and, for organization-scoped tokens, an active organization and membership. This is what makes logout and offboarding visible before the JWT's exp time.
Declare app.Api = "/api" (and app.Mcp = "/mcp" when you expose MCP) inside UseSingleApplication or ConfigureApplication. AddSqlOS registers the SqlOS JWT scheme with that API audience, and scheme/policy SqlOS.Mcp when Mcp is set. Call RequireAuthorization() on the groups you lock. Authentication sets HttpContext.User and stores the SqlOSValidatedToken on the current request:
using SqlOS.AuthServer.Extensions;
var validated = httpContext.GetSqlOSValidatedToken();
var clientId = validated?.ClientId;
var audience = validated?.Audience;Use GetSqlOSValidatedToken() for token diagnostics or auth-server metadata. Your app should still resolve the current FGA subject explicitly from the authenticated principal or from its own API-key/session mapping.
using System.IdentityModel.Tokens.Jwt;
var bearerToken = httpContext.Request.Headers.Authorization.ToString();
if (!bearerToken.StartsWith("Bearer "))
return Results.Unauthorized();
var expectedAudience = "https://api.example.com";
var validated = await authService.ValidateAccessTokenAsync(
bearerToken["Bearer ".Length..].Trim(),
expectedAudience,
ct);
if (validated == null)
return Results.Unauthorized();
var subjectId = validated.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
if (string.IsNullOrWhiteSpace(subjectId))
return Results.Unauthorized();
var orgId = validated.OrganizationId;ValidateAccessTokenAsync validates issuer, signature, JWT lifetime, exact audience, and that the referenced SqlOS session exists and is not revoked or absolutely expired. It does not apply the session idle timeout to each already-issued access token; idle expiry is enforced when the client refreshes.
SqlOS FGA APIs take explicit subjectId values. For bearer tokens, a typical app-owned resolver reads the JWT subject claim from the authenticated principal:
using System.IdentityModel.Tokens.Jwt;
var subjectId = http.User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
if (string.IsNullOrWhiteSpace(subjectId))
return Results.Unauthorized();
await db.ProvisionUserSubjectAsync(
subjectId,
displayName: subjectId,
cancellationToken: ct);If your app accepts API keys, agent tokens, or another credential type, resolve those credentials to the relevant service-account or agent subject ID in your own request layer, then pass that explicit subject ID to FGA.
ValidateAccessTokenWithoutAudienceForIntrospectionOnlyAsync exists only for diagnostics and token introspection flows that do not authenticate a protected API request. It validates issuer, signature, lifetime, and session existence/revocation/absolute expiry, but it intentionally does not validate aud.
Validation keeps the session check online for every request, so session revocation and absolute expiry take effect immediately. SqlOS reduces database churn by caching public validation keys briefly and by persisting session/client LastSeenAt at most once per configured debounce interval. Configure these intervals with AccessTokenValidationSigningKeyCacheTtl and AccessTokenValidationLastSeenDebounceInterval on AuthServer.
When a token names a kid missing from a replica's cache, SqlOS performs an authoritative refresh shared by concurrent validations. This lets a token issued immediately after rotation validate on another healthy replica without waiting for the normal cache TTL. Unknown-key refreshes are rate-limited across identifiers, retained negative identifiers are bounded, and an unsuccessful lookup does not extend the original cache expiry, so attacker-selected values cannot create an unbounded SQL or memory workload. If SQL is unavailable during the refresh, SqlOS keeps the last known public keys, logs the failure, and rejects the unknown key; it never accepts a token without the configured issuer, audience, algorithm, and signature checks.
External services can validate SqlOS JWTs without calling the SDK by using the JWKS endpoint:
GET /sqlos/auth/.well-known/jwks.jsonAnd the OAuth metadata endpoint:
GET /sqlos/auth/.well-known/oauth-authorization-serverJWKS-only validation is necessarily stateless. It cannot observe a SqlOS session revocation, idle expiry, user deactivation, organization deactivation, or membership removal until the JWT expires. Same-process hosts use the SqlOS scheme (RequireAuthorization()), which calls ValidateAccessTokenAsync. A separate API uses AddJwtBearer against JWKS and accepts revoke-at-exp. If a resource server must remain JWKS-only, keep access-token lifetimes short and treat that delay as an explicit deployment tradeoff.
| Claim | Description |
|---|---|
sub | User subject ID |
sid | Session ID |
client_id | OAuth client |
amr | Authentication methods for the session (for example password, totp) |
email | User's default email, when one exists |
org_id | Organization (if scoped) |
scope | The granted scope of the session's OAuth grant — the client's delegation ceiling. Omitted for sessions created before scope tracking and for direct (non-OAuth) logins, where the grant is unknown |
iss | Issuer URL |
aud | Audience |
nbf | Not valid before |
iat | Issued at |
exp | Expiration |
The scope claim records what the client application was granted, not what the user may do: per-user, per-resource authorization stays with FGA, and effective permission is the intersection of both. Service tokens from client-credentials machine clients also carry scope, plus token_kind: "service". See Scopes and Permissions.
To bound what a delegated client — especially a third-party one — may reach with a user's token, inspect the granted scope on the validated token in the handler. Tokens without a scope claim fail closed — their grant is unknown, and enforcement must not assume the widest one. Scope bounds the client's ceiling; it never replaces the FGA decision for the user.
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();