Headless Auth
Use SqlOS as the authorization server while your app owns the login UI.
Headless mode keeps the authorization server in SqlOS while moving the login UI into your application. SqlOS still owns /authorize, /token, PKCE, sessions, OIDC, SAML, and code issuance. Your app owns the HTML.
When to use headless
- The hosted auth page doesn't match your product's design
- You need app-owned signup fields (referral source, company name, etc.)
- You want to A/B test login and signup flows
- You want the OAuth popup to look like your app
If you're unsure, start with the hosted auth page. You can switch to headless later.
Configuration
builder.AddSqlOS<AppDbContext>(options =>
{
options.UseAuthServer(auth =>
{
auth.UseHeadlessAuthPage(headless =>
{
headless.BuildUiUrl = ctx =>
$"https://app.example.com/authorize?request={ctx.RequestId}&view={ctx.View}";
});
});
});
var app = builder.Build();
app.MapSqlOS();
When an OAuth client redirects to /authorize, SqlOS creates an authorization request and redirects to your BuildUiUrl. Your app takes over the UI from there.
Headless endpoints
Your custom UI interacts with SqlOS through these endpoints:
| Endpoint | Purpose |
|---|---|
GET /headless/requests/{requestId} | Get the current authorization request state |
POST /headless/identify | Submit email for home realm discovery |
POST /headless/password/login | Submit email + password |
POST /headless/signup | Create account with optional custom fields |
POST /headless/organization/select | Select org for multi-org users |
POST /headless/provider/start | Start OIDC or SSO flow |
Custom signup fields
Capture app-specific data during signup with customFields and validate it in OnHeadlessSignupAsync:
auth.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.UserId, referral);
};
});
Frontend implementation
The example app ships a complete headless implementation. Key files:
app/auth/authorize/page.tsx → Custom authorize route
components/sqlos-headless-auth-panel.tsx → Login/signup/org-selection UI
lib/sqlos-headless.ts → Typed client for headless endpoints
Typical flow in your custom authorize page:
// 1. Read the authorization request
const request = await getHeadlessRequest(requestId);
// 2. Show login or signup based on request.view
if (request.view === "signup") {
// Show signup form
}
// 3. User submits email
const identified = await headlessIdentify(requestId, email);
// identified.mode → "password" | "sso" | "signup"
// 4. User submits password
const result = await headlessPasswordLogin(requestId, email, password);
// 5. If multi-org, select organization
if (result.requiresOrganizationSelection) {
await headlessSelectOrganization(requestId, orgId);
}
// 6. SqlOS issues the auth code and redirects back to the OAuth client
What doesn't change
Headless mode does not affect the OAuth wire protocol. External clients (including MCP clients) still see:
/.well-known/oauth-authorization-server/authorize/token/.well-known/jwks.json
The only difference is what the user sees in the browser during authorization.