This guide covers how to start a SAML single sign-on (SSO) login from your web application. Make sure you have configured a SAML connection on your backend before proceeding.
SAML login flow
An SP-initiated SAML login involves three steps:
Initiate — Your app asks the SDK to start the flow; it redirects the user to the Identity Provider.
Callback — After authenticating, the IdP posts back and Prelude redirects to your app with a challenge_token.
Finalize — Your app sends the challenge_token to complete authentication.
The challenge_token is finalized exactly like social login — via finalizeOAuthLogin.
How is the SAML flow secured?
SP-initiated SAML is protected by PKCE (Proof Key for Code Exchange). The SDK generates a unique code_verifier/code_challenge pair for each login and stores the verifier locally. The IdP’s SAMLResponse is matched against the AuthnRequest it answers (InResponseTo), and the challenge_token can only be finalized once, by the browser that started the flow.
Redirect to the Identity Provider
There are two ways to start the flow.
By connection
When you know the connection, pass its providerId and connectionId:
await client . loginWithSAML ({
providerId: "okta" ,
connectionId: "samlc_01jqebhswje1ka1z7ahr9rfsgt" ,
redirectURI: "https://yourapp.com/callback" ,
});
By email
To resolve the connection from the user’s email domain (it must match exactly one enabled connection’s allowlist), use loginWithSAMLByEmail:
await client . loginWithSAMLByEmail ({
email: "jane@acme.com" ,
redirectURI: "https://yourapp.com/callback" ,
});
redirectURI is optional — it must be allowlisted for your app, and falls back to the connection’s default_redirect_uri when omitted.
Both methods navigate the browser to the IdP. If you need the IdP URL without
navigating (e.g. to open it yourself), call initiateSAMLLogin /
initiateSAMLLoginByEmail, which return { redirect_url }.
Handle the callback
When the IdP redirects back to your app, extract the challenge_token from the URL and finalize the login — the same call used for OAuth:
import { PrldErrors } from "@prelude.so/js-sdk" ;
const params = new URLSearchParams ( window . location . search );
const challengeToken = params . get ( "challenge_token" );
const error = params . get ( "error" );
if ( error ) {
// SAML validation or provisioning failure, e.g.
// saml_authentication_failed, saml_user_not_provisioned,
// saml_email_domain_not_allowed.
console . error ( params . get ( "error_description" ) || error );
} else if ( challengeToken ) {
try {
await client . finalizeOAuthLogin ( challengeToken );
// The user is now authenticated. SAML challenge tokens always
// resolve to { status: "logged_in" }.
await client . refresh ();
} catch ( err ) {
if ( err instanceof PrldErrors . ExpiredChallengeToken ) {
// Challenge token has expired — restart the flow
} else if ( err instanceof PrldErrors . InvalidChallengeToken ) {
// Challenge token is invalid
}
}
}
Replace src/App.jsx with: import "@picocss/pico" ;
import { useState , useEffect } from "react" ;
import { PrldSessionClient , PrldErrors , decode } from "@prelude.so/js-sdk" ;
const client = new PrldSessionClient ({ domain: ` ${ import . meta . env . VITE_APP_ID } .session.prelude.dev` });
export default function App () {
const [ user , setUser ] = useState ( null );
const [ email , setEmail ] = useState ( "" );
const [ error , setError ] = useState ( null );
const [ loading , setLoading ] = useState ( true );
useEffect (() => {
const init = async () => {
const params = new URLSearchParams ( window . location . search );
const challengeToken = params . get ( "challenge_token" );
const callbackError = params . get ( "error" );
if ( challengeToken || callbackError ) {
window . history . replaceState ({}, "" , window . location . pathname );
}
if ( callbackError ) {
setError ( params . get ( "error_description" ) || callbackError );
setLoading ( false );
return ;
}
if ( challengeToken ) {
try {
await client . finalizeOAuthLogin ( challengeToken );
} catch ( err ) {
if ( err instanceof PrldErrors . ExpiredChallengeToken ) {
setError ( "Login expired. Please try again." );
} else if ( err instanceof PrldErrors . InvalidChallengeToken ) {
setError ( "Invalid login. Please try again." );
} else {
setError ( "Something went wrong. Please try again." );
}
setLoading ( false );
return ;
}
}
try {
const { user } = await client . refresh ();
setUser ( user );
} catch {
// No active session
}
setLoading ( false );
};
void init ();
}, []);
const handleSAMLLogin = async () => {
setError ( null );
try {
await client . loginWithSAMLByEmail ({
email ,
redirectURI: window . location . origin + window . location . pathname ,
});
} catch ( err ) {
// The initiate endpoints reject an unknown or ambiguous email
// domain (saml_no_connection_for_email / saml_connection_ambiguous).
setError ( "No SSO connection is configured for that email domain." );
}
};
return (
<>
< style > { `body, #root { display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; padding: 0; }` } </ style >
< main style = { { textAlign: "center" , maxWidth: "600px" , width: "100%" } } >
< h1 > SAML Login </ h1 >
{ loading ? (
< p aria-busy = "true" > Completing login... </ p >
) : user ? (
< article >
< p > Logged in </ p >
< pre style = { { textAlign: "left" , whiteSpace: "pre-wrap" , wordBreak: "break-all" } } >
{ JSON . stringify ( decode ( user . accessToken ). claims , null , 2 ) }
</ pre >
< button className = "secondary" onClick = {async () => {
await client . logout ();
setUser ( null );
} } > Log Out </ button >
</ article >
) : (
<>
{ error && < p role = "alert" style = { { color: "var(--pico-color-red-500)" } } > { error } </ p > }
< input
type = "email"
placeholder = "you@company.com"
value = { email }
onChange = { ( e ) => setEmail ( e . target . value ) }
/>
< button onClick = { handleSAMLLogin } disabled = { ! email . trim () } >
Sign in with SSO
</ button >
</>
) }
</ main >
</>
);
}
What’s next?
If your application requires certain email domains to use SSO exclusively, see Enforce SSO login for handling the saml_login_required fallback.