Headless Auth How-To
Step-by-step implementation guide for custom authorize pages.
This is the implementation guide for Headless Auth. It covers the exact lifecycle and code your app needs.
What you implement
Your app provides four things:
- a
BuildUiUrl(...)function so SqlOS knows where your custom page lives - a custom authorize page
- a few
fetchcalls to the SqlOS headless endpoints - an optional
OnHeadlessSignupAsync(...)hook for app-specific signup data
SqlOS still provides:
/authorize/token- PKCE and
state - auth request persistence
- home realm discovery
- SAML and OIDC redirects
- callback handling
- authorization code issuance
- refresh/logout/session behavior
That is why headless mode works without a React SDK.
The lifecycle
This is the full lifecycle your app participates in.
- Your page starts a normal OAuth authorization request.
- SqlOS validates it and creates an authorization request record.
- SqlOS redirects the browser to your custom UI route.
- Your page loads the request state from
/headless/requests/{id}. - Your page posts user actions back to SqlOS:
- identify email
- password login
- signup
- start provider login
- select organization
- SqlOS either:
- returns the next view model, or
- returns a redirect to SAML/OIDC, or
- finishes the flow and redirects to the OAuth client callback with a real code
So the model is:
- your page is the UI
- SqlOS is the state machine
Step 1: Tell SqlOS where your UI lives
This is the most important part. In the Example app, the backend enables headless mode in Program.cs:
auth.UseHeadlessAuthPage(headless =>
{
headless.BuildUiUrl = ctx =>
{
var query = new Dictionary<string, string?>
{
["request"] = ctx.RequestId,
["view"] = ctx.View,
["error"] = ctx.Error,
["email"] = ctx.Email,
["pendingToken"] = ctx.PendingToken,
["displayName"] = ctx.DisplayName,
};
return QueryHelpers.AddQueryString(
$"{headlessFrontendUrl.TrimEnd('/')}/auth/authorize",
query);
};
});
That means:
- SqlOS still receives the real
GET /authorize - but when UI is needed, SqlOS redirects to your app page
- your page gets enough context to resume the flow
EasyCOI does the same thing, just with more app context in Program.cs:
auth.UseHeadlessAuthPage(headless =>
{
headless.BuildUiUrl = ctx =>
{
var language = uiContext?["lng"]?.GetValue<string?>()?.Trim() ?? "en";
var template = uiContext?["template"]?.GetValue<string?>();
var shellUrl = $"{firstPartyWebOrigin.TrimEnd('/')}/{language}/authorize";
var query = new Dictionary<string, string?>
{
["view"] = ctx.View,
["request"] = ctx.RequestId,
["error"] = ctx.Error,
["pendingToken"] = ctx.PendingToken,
["email"] = ctx.Email,
["displayName"] = ctx.DisplayName,
["template"] = string.IsNullOrWhiteSpace(template) ? null : template
};
return QueryHelpers.AddQueryString(shellUrl, query!);
};
});
The only difference is that EasyCOI threads language and template state through ui_context.
Step 2: Start a normal OAuth flow from your custom page
Your custom page still starts a real PKCE authorization request.
In the Example app, the starter lives in sqlos-headless-auth-panel.tsx:
const verifier = createOpaqueToken(48);
const state = createOpaqueToken(24);
const challenge = await createCodeChallenge(verifier);
persistSqlOSAuthFlow(flowView, state, verifier, "/app");
const url = new URL(`${getExampleAuthServerUrl()}/authorize`);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", getExampleClientId());
url.searchParams.set("redirect_uri", getExampleRedirectUri());
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
if (flowView === "signup") {
url.searchParams.set("view", "signup");
}
window.location.replace(url.toString());
EasyCOI does the same thing in HeadlessAuthorizeClient.tsx:
const url = new URL(`${apiUrl}/oauth/authorize`);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", SQL_OS_CLIENT_ID);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("view", view === "signup" ? "signup" : "login");
url.searchParams.set(
"ui_context",
JSON.stringify({
lng,
template: template || undefined,
}),
);
window.location.replace(url.toString());
This is important: the page is custom, but the request is still normal OAuth.
Step 3: Load the request model
After SqlOS redirects back to your custom page with ?request=req_..., your page loads the request state.
EasyCOI does that in HeadlessAuthorizeClient.tsx:
const requestUrl = new URL(`${apiUrl}/oauth/headless/requests/${requestId}`);
requestUrl.searchParams.set("view", requestedView);
if (queryError) {
requestUrl.searchParams.set("error", queryError);
}
const response = await fetch(requestUrl.toString(), {
credentials: "include",
cache: "no-store",
});
const payload = (await response.json()) as HeadlessModel;
setModel(payload);
That request model gives your page everything it needs to render the next step:
- current
view - current error
- organization choices
- available providers
- pending organization token
Step 4: Post user actions back to SqlOS
This is the main UI integration. Your page does not implement auth rules. It just posts user actions.
Email first / HRD
EasyCOI posts email identify in HeadlessAuthorizeClient.tsx:
await postAction("/oauth/headless/identify", {
requestId: model.requestId,
email,
});
SqlOS then decides:
- show password
- redirect to SAML
- or stay on the current page with an error
Password login
await postAction("/oauth/headless/password/login", {
requestId: model.requestId,
email,
password,
});
Signup
In the Example app, signup sends custom fields in sqlos-headless-auth-panel.tsx:
const result = await headlessSignup(
requestId,
buildDisplayName(firstName, lastName, email),
email,
password,
organizationName,
{
referralSource,
firstName,
lastName,
},
);
EasyCOI does the same pattern in HeadlessAuthorizeClient.tsx:
await postAction("/oauth/headless/signup", {
requestId: model.requestId,
displayName: `${firstName} ${lastName}`.trim(),
email,
password,
organizationName: companyName,
customFields: {
firstName,
lastName,
companyName,
language: lng,
template: template || undefined,
},
});
OIDC provider start
await postAction("/oauth/headless/provider/start", {
requestId: model.requestId,
connectionId,
email,
});
Organization select
await postAction("/oauth/headless/organization/select", {
pendingToken: model.pendingToken,
organizationId,
});
Step 5: Optionally persist app-owned signup data
This is the part that makes headless mode more than “hosted auth with different HTML.”
In the Example app, OnHeadlessSignupAsync(...) validates and persists referralSource into app-owned data in Program.cs:
headless.OnHeadlessSignupAsync = async (ctx, cancellationToken) =>
{
var referralSource = ctx.CustomFields?["referralSource"]?.GetValue<string>()?.Trim();
if (string.IsNullOrWhiteSpace(referralSource))
{
throw new SqlOSHeadlessValidationException(
"Tell us how you heard about SqlOS.",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["referralSource"] = "Select a referral source to complete signup."
});
}
var profile = await dbContext.ExampleUserProfiles
.FirstOrDefaultAsync(x => x.SqlOSUserId == ctx.User.Id, cancellationToken);
// create/update app-owned profile row here
};
EasyCOI delegates the same idea to an app service in Program.cs:
headless.OnHeadlessSignupAsync = async (ctx, cancellationToken) =>
{
var provisioner = ctx.HttpContext.RequestServices
.GetRequiredService<IEasyCoiHeadlessSignupProvisioningService>();
await provisioner.HandleAsync(ctx, cancellationToken);
};
That is the clean pattern:
- SqlOS owns the auth records
- your app owns the extra product data
Step 6: Let SqlOS finish the flow
This is the part you do not write.
When your page posts an action, SqlOS returns one of two things:
- a new view model
- a redirect URL
In the Example app, the UI reacts like this in sqlos-headless-auth-panel.tsx:
if (result.type === "redirect" && result.redirectUrl) {
const url = new URL(result.redirectUrl);
const code = url.searchParams.get("code");
if (code) {
// exchange code, create session, enter app
}
window.location.href = result.redirectUrl;
return;
}
if (result.viewModel) {
setViewModel(result.viewModel);
}
That is the whole contract:
- if SqlOS wants the browser somewhere else, follow the redirect
- if SqlOS wants the app to render another step, render the next view model
Minimum implementation checklist
If you are building a consumer app, this is the minimum lifecycle to implement:
- Configure
UseHeadlessAuthPage(...) - Implement
BuildUiUrl(...) - Create a custom authorize page route
- Start a real PKCE
/authorizerequest from that page - Load
GET /headless/requests/{requestId} - Post the five user actions back to SqlOS
- Follow redirect results
- Optionally implement
OnHeadlessSignupAsync(...)for app-specific fields
That is all you need.
What you do not need to implement
You do not need to write:
- a React SDK
- authorization code issuance
- redirect URI validation
- PKCE validation
- OIDC callback logic
- SAML ACS handling
- token issuance rules
- refresh rotation
That is the entire point of headless mode.
Copy this pattern
If you want a starting point:
- Example API backend: Program.cs
- Example web UI: sqlos-headless-auth-panel.tsx
- EasyCOI backend: Program.cs
- EasyCOI UI: HeadlessAuthorizeClient.tsx
If you want the product framing, go back to Headless Auth and Headless Auth Server Mode: Keep OAuth, Own the UI.