AuthServer
Headless Auth
Use SqlOS as the authorization server while your app owns the login UI.
Headless = SqlOS runs OAuth (/sqlos/auth/authorize, /sqlos/auth/token, PKCE, sessions, OIDC, SAML, codes). Your app draws the HTML.
Not sure? Start hosted. You can switch later.
builder.AddSqlOS<AppDbContext>(options =>
{
options.AuthServer.UseHeadlessAuthPage(headless =>
{
headless.BuildUiUrl = ctx =>
QueryHelpers.AddQueryString(
"https://app.example.com/authorize",
new Dictionary<string, string?>
{
["request"] = ctx.RequestId,
["view"] = ctx.View,
["email"] = ctx.Email,
["pendingToken"] = ctx.PendingToken,
["mfaToken"] = ctx.MfaToken,
["ui_context"] = ctx.UiContext?.ToJsonString()
});
});
options.AuthServer.SeedAuthPage(page =>
{
page.PageTitle = "Sign in";
});
});
var app = builder.Build();
app.MapSqlOS();The browser hits /sqlos/auth/authorize on SqlOS first. SqlOS then redirects to the URL from BuildUiUrl. Your app shows the login UI. Preserve ui_context values on the page model when present, but do not invent separate auth lifecycles for invitations or CLI device flow; SqlOS binds those to the normal request id.
SqlOS uses one rule:
BuildUiUrl exists: headless.BuildUiUrl does not exist: hosted.There is no second seed or dashboard switch to keep in sync.
Implementation guide: Build your own login and signup UI. More context: Hosted vs Headless.
Your UI calls these SqlOS endpoints:
| Endpoint | Purpose |
|---|---|
GET /sqlos/auth/headless/requests/{requestId} | Get the current authorization request state |
POST /sqlos/auth/headless/identify | Submit email for home realm discovery |
POST /sqlos/auth/headless/password/login | Submit email + password |
POST /sqlos/auth/headless/email-otp/start | Send an existing-user email code |
POST /sqlos/auth/headless/email-otp/verify | Verify an existing-user email code |
POST /sqlos/auth/headless/magic-link/start | Send an existing-user sign-in link |
POST /sqlos/auth/headless/magic-link/complete | Consume the sign-in link token |
POST /sqlos/auth/headless/phone-otp/start | Send an existing-user phone code through Twilio Verify |
POST /sqlos/auth/headless/phone-otp/verify | Verify an existing-user phone code |
POST /sqlos/auth/headless/signup | Create account with optional custom fields |
POST /sqlos/auth/headless/signup/email-otp/start | Send a new-user signup code |
POST /sqlos/auth/headless/signup/email-otp/verify | Verify signup code and complete authorization |
POST /sqlos/auth/headless/signup/phone-otp/start | Send a new-user phone signup code |
POST /sqlos/auth/headless/signup/phone-otp/verify | Verify phone signup and continue authorization |
POST /sqlos/auth/headless/invitations/resolve | Resolve an invitation token before starting OAuth |
POST /sqlos/auth/headless/invitations/signup | Create an invited passwordless account and accept the invite |
POST /sqlos/auth/headless/device/resolve | Resolve a CLI device user code |
POST /sqlos/auth/headless/device/approve | Approve a CLI device request after sign-in |
POST /sqlos/auth/headless/device/deny | Deny a CLI device request |
POST /sqlos/auth/headless/organization/select | Select org for multi-org users |
POST /sqlos/auth/headless/mfa/verify | Verify a TOTP or recovery-code challenge |
POST /sqlos/auth/headless/mfa/totp/enroll/start | Start forced TOTP enrollment for an MFA challenge |
POST /sqlos/auth/headless/mfa/totp/enroll/verify | Verify forced TOTP enrollment and continue authorization |
POST /sqlos/auth/headless/provider/start | Start OIDC or SSO flow |
Phone-code routes require both a configured Twilio runtime and phone_otp in the effective AuthPage credential list. See Phone OTP with Twilio Verify for configuration, request contracts, rate limits, and test adapters.
When a CLI sends the user to /sqlos/auth/device?user_code=..., SqlOS resolves the device request and creates a normal headless authorization request bound to that device authorization. Your app receives the same request id shape used by login, signup, OTP, SSO, and organization selection.
The lowest-friction pattern is:
/headless/requests/{requestId} and render your normal auth UI. The view may be device, login, email-otp, phone-otp, password, organization, or device-approve.device-approve, show the CLI client/resource/scope from deviceAuthorization./headless/device/approve or /headless/device/deny, passing the requestId. You may pass userCode instead for manual-entry UIs, but request-bound approval is preferred.SqlOS releases tokens to the polling CLI only after approval. See CLI OAuth.
Pass extra signup data in customFields. Validate in OnHeadlessSignupAsync:
options.AuthServer.UseHeadlessAuthPage(headless =>
{
headless.BuildUiUrl = ctx =>
$"https://app.example.com/authorize?request={ctx.RequestId}&view={ctx.View}";
headless.OnHeadlessSignupAsync = async (ctx, ct) =>
{
var referral = ctx.CustomFields["referralSource"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(referral))
{
throw new SqlOSHeadlessValidationException(
"Tell us how you heard about the product.",
new Dictionary<string, string>
{
["referralSource"] = "Required."
});
}
// Persist to your own table
await SaveProfileAsync(ctx.User.Id, referral);
};
});The example repository has complete Next.js and Angular clients. Use Build your own login and signup UI for the source-aligned implementation flow, including state and S256 PKCE, credentialed CORS, server-owned view transitions, callback validation, and code exchange.
The headless routes are JSON integration endpoints, not SqlOS-hosted HTML forms, so they do not expect the hosted __RequestVerificationToken field. Native clients do not have browser cookie-CSRF exposure. For browser frontends, use an exact credentialed CORS allowlist or a same-origin/backend-for-frontend boundary; never combine credentialed requests with a reflected or wildcard origin. If your own UI host accepts cookie-authenticated form actions, protect those actions with that host's antiforgery mechanism. OAuth state, PKCE, and SqlOS request binding remain required but solve different substitution problems.
The core files are:
examples/SqlOS.Example.Web/app/auth/authorize/page.tsx
examples/SqlOS.Example.Web/components/sqlos-headless-auth-panel.tsx
examples/SqlOS.Example.Web/lib/sqlos-headless.ts
examples/SqlOS.Example.AngularWeb/src/app/services/sqlos-headless.service.tsHeadless MFA uses the same result contract as every other step.
When viewModel.view is mfa, render an input for a 6-digit authenticator code or a recovery code. Post it to /headless/mfa/verify with requestId, mfaToken, and code.
When viewModel.view is mfa-enroll, render authenticator setup. The view model includes totpEnrollment with qrCodeDataUrl, secret, provisioningUri, enrollmentToken, and expiresAt. Post the verification code to /headless/mfa/totp/enroll/verify.
Keep requestId, mfaToken, and enrollmentToken from the same view model
together. They are transaction-bound and cannot be substituted across users,
clients, organizations, or parallel authorization requests. A normal mfa
view never permits starting enrollment; first-factor completion by itself is
not authorization to add or replace an authenticator.
If your headless app does not render these views, required-MFA users will authenticate their primary credential and then stop at an unhandled state.
Headless invitation links should use the invitation endpoints instead of pretending the invite is a normal signup.
// Resolve token shown in the emailed link.
const invite = await headlessResolveInvitation(invitationToken);
// Start OAuth and include invitationToken in the authorize request.
// SqlOS binds the invitation to the saved auth request.
// Existing user: continue through identify, SSO, password, or email OTP.
// New invited user in passwordless mode:
await headlessSignupWithInvitation(
requestId,
displayName,
invite.email,
customFields,
invitationToken,
);Calling /signup/email-otp/start for a new invited user is the wrong shape: the invite link has already proven mailbox possession. Use /invitations/signup so SqlOS creates the verified user, accepts the invitation, and issues the redirect in one transaction. If HRD requires SSO for the invited email domain, SqlOS returns the SSO redirect instead of creating a local account.
OAuth on the wire is the same. MCP and other clients still use:
/sqlos/auth/.well-known/oauth-authorization-server/sqlos/auth/authorize/sqlos/auth/token/sqlos/auth/.well-known/jwks.jsonOnly the browser UI during login changes.