Skip to content

SSO Documentation

Your customers are asking:

  • “Can we use Okta to log in?”
  • “Does your product work with Entra/Azure AD?”
  • “Do you support SSO?”

PropelAuth BYO SSO lets you say “yes” to all of these. You add OpenID Connect (OIDC) support that works with Okta, Azure AD/Entra, Google Workspace, Ping Identity, JumpCloud, and any other OIDC-compliant identity provider.

To add SSO, you need three things:

  1. A way for customers to set up their SSO connection - They give you their IdP details, you store them
  2. Two backend endpoints - One is where users start the SSO flow, the other is where they land after authenticating
  3. A simple frontend flow - Augment your login page to handle SSO

In the end, the user experience will look something like this:

Example of a user logging in via OIDC

A good way to think about SSO is that you need to set up a login method that only works for one specific customer. To make this work, you need to collect some information from your customer, and they need to configure their IdP to point to your app.

The information you need to collect from your customer depends on which identity provider they use, but generally includes:

  • Client ID: A public identifier for the client app in the IdP.
  • Client Secret: A secret known only to the IdP and the client app.
  • IdP: The identity provider (Okta, Entra, or Generic).
  • OIDC Endpoints (authorization, token, userinfo): Sometimes you can infer these from the IdP’s domain, other times you need to ask your customer to provide them. See Idp Info below for more information.

Example of a user setting up an OIDC client

You will also need to provide your customer with a Redirect URL (also known as a Callback URL) that they need to provide to their IdP. This is where users will be redirected to after authenticating with their identity provider.

We recommend providing guides for popular IdPs to help your customers in collecting the correct information and configuring their IdPs to point to your app. See our Example OIDC Setup Guides for inspiration.

Once you have collected the necessary information from your customer, you can call the Create OIDC Client function on your backend.

// Your admin endpoint for SSO setup
app.post("/api/admin/sso/setup", async (req, res) => {
// Get the organization your user is in (your logic)
const organizationId = getOrganizationIdFromRequest(req);
// Create the OIDC client in PropelAuth BYO
const result = await auth.sso.management.createOidcClient({
// This links the SSO config to your customer
customerId: organizationId,
// Make sure to provide this value to your customer,
// they'll need it when setting up their IdP
redirectUrl: "https://yourapp.com/api/auth/callback",
idpInfoFromCustomer: {
// When idpType is "Okta", we are able to infer some values compared to "Generic"
// All these values will be provided by your customer
idpType: "Okta",
ssoDomain: "acme-corp.okta.com",
clientId: req.body.clientId,
clientSecret: req.body.clientSecret,
usesPkce: true,
},
});
if (result.ok) {
res.json({ success: true });
} else {
res.status(400).json({ error: result.error });
}
});
from pydantic import BaseModel
from propelauth_byo.generated.idp_info_from_customer import IdpInfoFromCustomerOkta
class SetupSSORequest(BaseModel):
client_id: str
client_secret: str
# Your admin endpoint for SSO setup
@app.post("/api/admin/sso/setup")
async def setup_sso(body: SetupSSORequest, request: Request):
# Get the organization your user is in (your logic)
organization_id = get_organization_id_from_request(request)
# Create the OIDC client in PropelAuth BYO
result = await client.sso.management.create_oidc_client(
# This links the SSO config to your customer
customer_id=organization_id,
# Make sure to provide this value to your customer,
# they'll need it when setting up their IdP
redirect_url="https://yourapp.com/api/auth/callback",
idp_info_from_customer=IdpInfoFromCustomerOkta(
# When idpType is "Okta", we are able to infer some values compared to "Generic"
# All these values will be provided by your customer
# Also available: IdpInfoFromCustomerMicrosoftEntra, IdpInfoFromCustomerGeneric
sso_domain="acme-corp.okta.com",
client_id=body.client_id,
client_secret=body.client_secret,
uses_pkce=True,
),
)
if is_err(result):
raise HTTPException(status_code=400, detail=result.error)
return {"success": True}
// Your admin endpoint for SSO setup
@PostMapping("/api/admin/sso/setup")
public ResponseEntity<?> setupSso(@RequestBody SetupSSORequest body, HttpServletRequest request) {
// Get the organization your user is in (your logic)
String organizationId = getOrganizationIdFromRequest(request);
try {
// Create the OIDC client in PropelAuth BYO
CreateOidcClientResponse result = client.sso.management.createOidcClient(
CreateOidcClientCommand.builder()
// This links the SSO config to your customer
.customerId(organizationId)
// Make sure to provide this value to your customer,
// they'll need it when setting up their IdP
.redirectUrl("https://yourapp.com/api/auth/callback")
.idpInfoFromCustomer(
// When idpType is "Okta", we are able to infer some values compared to "Generic"
// All these values will be provided by your customer
// Also available: IdpInfoFromCustomer.MicrosoftEntra, IdpInfoFromCustomer.Generic
IdpInfoFromCustomer.Okta.builder()
.ssoDomain("acme-corp.okta.com")
.clientId(body.getClientId())
.clientSecret(body.getClientSecret())
.usesPkce(true)
.build()
)
.build()
);
return ResponseEntity.ok(Map.of("success", true));
} catch (CreateOidcClientException e) {
return ResponseEntity.status(400).body(Map.of("error", e.getMessage()));
}
}
public record SetupSSORequest(string ClientId, string ClientSecret);
// Your admin endpoint for SSO setup
[HttpPost("/api/admin/sso/setup")]
public async Task<IActionResult> SetupSso([FromBody] SetupSSORequest body)
{
// Get the organization your user is in (your logic)
var organizationId = GetOrganizationIdFromRequest(Request);
try
{
// Create the OIDC client in PropelAuth BYO
var result = await client.Sso.Management.CreateOidcClientAsync(new CreateOidcClientCommand
{
// This links the SSO config to your customer
CustomerId = organizationId,
// Make sure to provide this value to your customer,
// they'll need it when setting up their IdP
RedirectUrl = "https://yourapp.com/api/auth/callback",
IdpInfoFromCustomer = new IdpInfoFromCustomerOkta
{
// When idpType is "Okta", we are able to infer some values compared to "Generic"
// All these values will be provided by your customer
// Also available: IdpInfoFromCustomerMicrosoftEntra, IdpInfoFromCustomerGeneric
SsoDomain = "acme-corp.okta.com",
ClientId = body.ClientId,
ClientSecret = body.ClientSecret,
UsesPkce = true
}
});
return Ok(new { Success = true });
}
catch (CreateOidcClientException ex)
{
return StatusCode(400, new { Error = ex.Message });
}
}

You have now set up an OIDC client for one of your customers! The next step is to add the SSO login flow to your app.

Once SSO is set up for an organization, here’s how users log in:

Your user enters their email on your login page. You need to map this to a customer/organization ID.

// Your existing logic to map email → organization
const organizationId = await getOrganizationFromEmail(email);
if (!organizationId) {
// Handle regular login or show error
return;
}
# Your existing logic to map email → organization
organization_id = await get_organization_from_email(email)
if not organization_id:
# Handle regular login or show error
return
// Your existing logic to map email → organization
String organizationId = getOrganizationFromEmail(email);
if (organizationId == null) {
// Handle regular login or show error
return;
}
// Your existing logic to map email → organization
var organizationId = await GetOrganizationFromEmail(email);
if (organizationId == null)
{
// Handle regular login or show error
return;
}

We’ll use this ID in the next step to look up the SSO configuration for this organization.

Use the Initiate OIDC Login function to start the SSO flow:

const result = await client.sso.initiateOidcLogin({
// Use the customerId we specified when setting up SSO
customerId: organizationId,
});
if (!result.ok) {
if (result.error.type === "ClientNotFound") {
// This organization doesn't have SSO configured
// Fall back to regular login
} else {
// Handle other errors
}
return;
}
// The result contains two important values:
const { stateForCookie, sendUserToIdpUrl } = result.data;
// Set the state as a cookie (needed for CSRF protection)
res.cookie("oidc_state", stateForCookie, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 5 * 60, // 5 minutes
});
// Redirect user to their IdP
res.redirect(sendUserToIdpUrl);
result = await client.sso.initiate_oidc_login(
# Use the customerId we specified when setting up SSO
customer_id=organization_id,
)
if is_err(result):
if result.error.type == "ClientNotFound":
# This organization doesn't have SSO configured
# Fall back to regular login
raise HTTPException(status_code=404, detail="SSO not configured")
else:
# Handle other errors
raise HTTPException(status_code=500, detail="SSO login failed")
return
# The result contains two important values:
state_for_cookie = result.data.state_for_cookie
send_user_to_idp_url = result.data.send_user_to_idp_url
# Set the state as a cookie (needed for CSRF protection)
response.set_cookie(
"oidc_state",
state_for_cookie,
httponly=True,
secure=True,
samesite="lax",
max_age=5 * 60 # 5 minutes
)
# Redirect user to their IdP
return RedirectResponse(url=send_user_to_idp_url)
try {
InitiateOidcLoginResponse result = client.sso.initiateOidcLogin(
InitiateOidcLoginCommand.byCustomerId(
// Use the customerId we specified when setting up SSO
organizationId
)
);
// The result contains two important values:
String stateForCookie = result.getStateForCookie();
String sendUserToIdpUrl = result.getSendUserToIdpUrl();
// Set the state as a cookie (needed for CSRF protection)
Cookie cookie = new Cookie("oidc_state", stateForCookie);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setPath("/");
cookie.setMaxAge(5 * 60); // 5 minutes
response.addCookie(cookie);
// Redirect user to their IdP
response.sendRedirect(sendUserToIdpUrl);
} catch (InitiateOidcLoginException.ClientNotFound e) {
// This organization doesn't have SSO configured
// Fall back to regular login
} catch (InitiateOidcLoginException e) {
// Handle other errors
}
try
{
var result = await client.Sso.InitiateOidcLoginByCustomerIdAsync(
// Use the customerId we specified when setting up SSO
organizationId
);
// The result contains two important values:
var stateForCookie = result.StateForCookie;
var sendUserToIdpUrl = result.SendUserToIdpUrl;
// Set the state as a cookie (needed for CSRF protection)
Response.Cookies.Append("oidc_state", stateForCookie, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
MaxAge = TimeSpan.FromMinutes(5)
});
// Redirect user to their IdP
return Redirect(sendUserToIdpUrl);
}
catch (InitiateOidcLoginException.ClientNotFound)
{
// This organization doesn't have SSO configured
// Fall back to regular login
}
catch (InitiateOidcLoginException)
{
// Handle other errors
}

After the user authenticates at their IdP, they’re redirected to your callback URL. Handle this in your backend with the Complete OIDC Login function:

app.get("/api/auth/callback", async (req, res) => {
const result = await auth.sso.completeOidcLogin({
stateFromCookie: req.cookies.oidc_state,
// BYO takes in the full URL including query params to finish the login
callbackPathAndQueryParams: req.originalUrl,
});
if (!result.ok) {
// Handle various error types
if (result.error.type === "LoginBlockedByEmailAllowlist") {
// User's email not in allowlist
} else if (result.error.type === "ScimUserNotActive") {
// User deprovisioned via SCIM
}
// ... handle other errors
return;
}
// Success! You get the user data
const ssoUser = result.data;
console.log(ssoUser.email);
console.log(ssoUser.oidcUserId); // Stable ID from IdP
console.log(ssoUser.dataFromSso); // Additional claims from IdP
// Now log them in however you normally would
});
def get_callback_url(request: Request) -> str:
callback_url = str(request.url.path)
if request.url.query:
callback_url += "?" + str(request.url.query)
return callback_url
@app.get("/api/auth/callback")
async def auth_callback(request: Request, response: Response):
result = await client.sso.complete_oidc_login(
state_from_cookie=request.cookies.get("oidc_state"),
# BYO takes in the full URL including query params to finish the login
callback_path_and_query_params=get_callback_url(request),
)
if is_err(result):
# Handle various error types
if result.error.type == "LoginBlockedByEmailAllowlist":
# User's email not in allowlist
raise HTTPException(status_code=403, detail="Authentication failed")
elif result.error.type == "ScimUserNotActive":
# User deprovisioned via SCIM
raise HTTPException(status_code=403, detail="Authentication failed")
# ... handle other errors
raise HTTPException(status_code=500, detail="Internal server error")
# Success! You get the user data
sso_user = result.data
print(sso_user.email)
print(sso_user.oidc_user_id) # Stable ID from IdP
print(sso_user.data_from_sso) # Additional claims from IdP
# Now log them in however you normally would
@GetMapping("/api/auth/callback")
public ResponseEntity<?> authCallback(
@CookieValue(name = "oidc_state", required = false) String oidcState,
HttpServletRequest request
) {
String callbackUrl = request.getRequestURI();
if (request.getQueryString() != null) {
callbackUrl += "?" + request.getQueryString();
}
try {
CompleteOidcLoginResponse ssoUser = client.sso.completeOidcLogin(
CompleteOidcLoginCommand.builder()
.stateFromCookie(oidcState)
// BYO takes in the full URL including query params to finish the login
.callbackPathAndQueryParams(callbackUrl)
.build()
);
// Success! You get the user data
System.out.println(ssoUser.getEmail());
System.out.println(ssoUser.getOidcUserId()); // Stable ID from IdP
System.out.println(ssoUser.getDataFromSso()); // Additional claims from IdP
// Now log them in however you normally would
return ResponseEntity.ok(Map.of("success", true));
} catch (CompleteOidcLoginException.LoginBlockedByEmailAllowlist e) {
// User's email not in allowlist
return ResponseEntity.status(403).body(Map.of("error", "Authentication failed"));
} catch (CompleteOidcLoginException.ScimUserNotActive e) {
// User deprovisioned via SCIM
return ResponseEntity.status(403).body(Map.of("error", "Authentication failed"));
} catch (CompleteOidcLoginException e) {
// ... handle other errors
return ResponseEntity.status(500).body(Map.of("error", "Internal server error"));
}
}
[HttpGet("/api/auth/callback")]
public async Task<IActionResult> AuthCallback()
{
string callbackUrl = Request.Path.Value;
if (Request.QueryString.HasValue)
{
callbackUrl += Request.QueryString.Value;
}
try
{
var ssoUser = await client.Sso.CompleteOidcLoginAsync(new CompleteOidcLoginCommand
{
StateFromCookie = Request.Cookies["oidc_state"],
// BYO takes in the full URL including query params to finish the login
CallbackPathAndQueryParams = callbackUrl
});
// Success! You get the user data
Console.WriteLine(ssoUser.Email);
Console.WriteLine(ssoUser.OidcUserId); // Stable ID from IdP
Console.WriteLine(ssoUser.DataFromSso); // Additional claims from IdP
// Now log them in however you normally would
return Ok(new { Success = true });
}
catch (CompleteOidcLoginException.LoginBlockedByEmailAllowlist)
{
// User's email not in allowlist
return StatusCode(403, new { Error = "Authentication failed" });
}
catch (CompleteOidcLoginException.ScimUserNotActive)
{
// User deprovisioned via SCIM
return StatusCode(403, new { Error = "Authentication failed" });
}
catch (CompleteOidcLoginException)
{
// ... handle other errors
return StatusCode(500, new { Error = "Internal server error" });
}
}

When your customers set up their OIDC client in their IdP, they need to specify a redirect URL - this is where users land after authenticating:

https://yourapp.com/api/auth/callback

This URL must:

  • Match exactly what you handle in your backend (from Step 3 above)
  • Be publicly accessible
  • Use HTTPS in production

Give customers the exact URL for their environment when they’re setting up SSO.

Included in the Create OIDC Client command is an IdP Info From Customer argument. This argument accepts an object that contains information about the identity provider you are connecting to. In the object is an IdP Type field that accepts one of the following values:

  • Okta
  • MicrosoftEntra
  • Generic

Depending on which you set, the required fields for the IdP Info object may vary.

// Okta
idpInfoFromCustomer: {
idpType: "Okta",
ssoDomain: "acme-inc.okta.com",
clientId: "5c891426-22b...",
clientSecret: "aNb8Q~-v2...",
usesPkce: true
}
// Microsoft Entra / Azure
idpInfoFromCustomer: {
idpType: "MicrosoftEntra",
tenantId: "b1795839-a1b...",
clientId: "5c891426-22b...",
clientSecret: "aNb8Q~-v2...",
usesPkce: true
}
// Other/Generic
idpInfoFromCustomer: {
idpType: "Generic",
authUrl: "https://identity-provider.com/oauth/authorize",
tokenUrl: "https://identity-provider.com/oauth/token",
userinfoUrl: "https://identity-provider.com/oauth/userinfo",
clientId: "5c891426-22b...",
clientSecret: "aNb8Q~-v2...",
usesPkce: true
}

For more information on how to configure OIDC with specific identity providers, refer to our Example OIDC Setup Guides.

If you are used to implementing basic SSO (i.e. log in with Apple, log in with GitHub), you might be used to email addresses automatically being verified.

Enterprise SSO providers, on the other hand, often don’t verify emails. In addition to that, many enterprises that set up SSO often dislike the idea of email verification links being sent to their users, because the assumption is that SSO is already a strong verification of identity.

The most common approach here is to say, “I know that I’m setting up SSO for acme.com. If a user tries to log in with an acme.com email, they are allowed to login. If they try to log in with any other email, block them.”

When you Create the OIDC Client, you can specify an allowedEmailDomains field which does exactly that. Any users logging in with an email outside of that domain will be blocked from logging in. You can also manage this setting in the dashboard.