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:
- A way for customers to set up their SSO connection - They give you their IdP details, you store them
- Two backend endpoints - One is where users start the SSO flow, the other is where they land after authenticating
- A simple frontend flow - Augment your login page to handle SSO
In the end, the user experience will look something like this:

Setting up an SSO Connection
Section titled “Setting up an SSO Connection”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.
Collecting Information from Your Customer
Section titled “Collecting Information from Your Customer”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.

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.
Creating the OIDC Client
Section titled “Creating the OIDC Client”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 setupapp.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 BaseModelfrom 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.
The Login Flow
Section titled “The Login Flow”Once SSO is set up for an organization, here’s how users log in:
Step 1: User enters their email
Section titled “Step 1: User enters their email”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 → organizationconst organizationId = await getOrganizationFromEmail(email);if (!organizationId) { // Handle regular login or show error return;}# Your existing logic to map email → organizationorganization_id = await get_organization_from_email(email)if not organization_id: # Handle regular login or show error return// Your existing logic to map email → organizationString organizationId = getOrganizationFromEmail(email);if (organizationId == null) { // Handle regular login or show error return;}// Your existing logic to map email → organizationvar 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.
Step 2: Initiate the SSO flow
Section titled “Step 2: Initiate the SSO flow”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 IdPres.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_cookiesend_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 IdPreturn 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}Step 3: Handle the callback
Section titled “Step 3: Handle the callback”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" }); }}Callback URL Configuration
Section titled “Callback URL Configuration”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/callbackThis 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.
IdP Info
Section titled “IdP Info”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:
OktaMicrosoftEntraGeneric
Depending on which you set, the required fields for the IdP Info object may vary.
// OktaidpInfoFromCustomer: { idpType: "Okta", ssoDomain: "acme-inc.okta.com", clientId: "5c891426-22b...", clientSecret: "aNb8Q~-v2...", usesPkce: true}
// Microsoft Entra / AzureidpInfoFromCustomer: { idpType: "MicrosoftEntra", tenantId: "b1795839-a1b...", clientId: "5c891426-22b...", clientSecret: "aNb8Q~-v2...", usesPkce: true}
// Other/GenericidpInfoFromCustomer: { 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.
Email Verification
Section titled “Email Verification”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.