Documentation

Headless Auth

← All docs

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:

EndpointPurpose
GET /headless/requests/{requestId}Get the current authorization request state
POST /headless/identifySubmit email for home realm discovery
POST /headless/password/loginSubmit email + password
POST /headless/signupCreate account with optional custom fields
POST /headless/organization/selectSelect org for multi-org users
POST /headless/provider/startStart 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.