Skip to main content
When a SAML connection has enforce login enabled, users whose email domain is covered by the connection must authenticate through SSO. Other login methods are refused — and the Web SDK gives you a typed signal so you can route those users into SAML without showing them an error.

The saml_login_required error

If your app starts an OTP login for an enforced email, the server responds with 403 saml_login_required. startOTP routes failures through the SDK’s error mapper, so it throws a typed SAMLLoginRequiredError, exported as PrldErrors.SAMLLoginRequired:
import { PrldErrors } from "@prelude.so/js-sdk";

try {
  await client.startOTP({
    identifier: { type: "email_address", value: email },
  });
  // OTP sent — show your code-entry screen.
} catch (err) {
  if (err instanceof PrldErrors.SAMLLoginRequired) {
    // This domain enforces SSO — restart via SAML instead.
  } else {
    // Handle other errors (rate limiting, invalid identifier, …).
  }
}

Fall back to SAML

On SAMLLoginRequiredError, restart the flow with loginWithSAMLByEmail. It resolves the connection from the same email domain and redirects the user to the Identity Provider:
import { PrldErrors } from "@prelude.so/js-sdk";

async function startEmailLogin(email) {
  try {
    await client.startOTP({
      identifier: { type: "email_address", value: email },
    });
    // OTP path: show the code-entry screen.
  } catch (err) {
    if (err instanceof PrldErrors.SAMLLoginRequired) {
      // Enforced domain — hand off to SSO. This navigates to the IdP.
      await client.loginWithSAMLByEmail({
        email,
        redirectURI: window.location.origin + window.location.pathname,
      });
      return;
    }
    throw err;
  }
}
The user authenticates with the IdP and is redirected back with a challenge_token, which you finalize exactly as in the SAML Login guide.
The fallback is transparent: the user enters their email expecting an OTP and is seamlessly redirected to their company’s SSO instead. No separate “Sign in with SSO” button is required.
This single email field starts an OTP login and silently upgrades to SAML when the domain enforces SSO.
src/App.jsx
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 [code, setCode] = useState("");
  const [otpSent, setOtpSent] = useState(false);
  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);
      } else if (challengeToken) {
        // Returning from the IdP — finalize the SAML login.
        try {
          await client.finalizeOAuthLogin(challengeToken);
        } catch {
          setError("SSO login failed. Please try again.");
        }
      }

      try {
        const { user } = await client.refresh();
        setUser(user);
      } catch {
        // No active session
      }
      setLoading(false);
    };
    void init();
  }, []);

  const handleEmail = async () => {
    setError(null);
    try {
      await client.startOTP({ identifier: { type: "email_address", value: email } });
      setOtpSent(true);
    } catch (err) {
      if (err instanceof PrldErrors.SAMLLoginRequired) {
        // Enforced domain — redirect to the IdP.
        await client.loginWithSAMLByEmail({
          email,
          redirectURI: window.location.origin + window.location.pathname,
        });
      } else {
        setError("Could not start login. Please try again.");
      }
    }
  };

  const handleCode = async () => {
    setError(null);
    try {
      await client.checkOTP({ code });
      const { user } = await client.refresh();
      setUser(user);
    } catch {
      setError("Invalid code. Please try again.");
    }
  };

  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>Sign in</h1>
        {loading ? (
          <p aria-busy="true">Loading...</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>}
            {!otpSent ? (
              <>
                <input type="email" placeholder="you@company.com" value={email} onChange={(e) => setEmail(e.target.value)} />
                <button onClick={handleEmail} disabled={!email.trim()}>Continue</button>
              </>
            ) : (
              <>
                <input inputMode="numeric" placeholder="123456" value={code} onChange={(e) => setCode(e.target.value)} />
                <button onClick={handleCode} disabled={!code.trim()}>Verify</button>
              </>
            )}
          </>
        )}
      </main>
    </>
  );
}

What’s next?

Read the Enforce SSO login integration guide for the backend configuration, or the SAML Login guide for the standard flow.