Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.prelude.so/llms.txt

Use this file to discover all available pages before exploring further.

This guide covers how to implement social login (OAuth) in your web application. Make sure you have configured a social login provider on your backend before proceeding.

OAuth login flow

The social login flow involves three steps:
  1. Redirect — Your app redirects the user to the provider’s login page
  2. Callback — The provider redirects back to your app with a challenge_token
  3. Finalize — Your app sends the challenge_token to complete authentication
When the OAuth provider has verify_email enabled and the IdP returns an email it has not verified, an extra OTP step happens between Callback and Finalize — see Verify email via OTP below.
The entire flow is protected by PKCE (Proof Key for Code Exchange). The SDK generates a unique code_verifier and code_challenge pair for each login attempt, and the challenge_token can only be finalized once. This protects against:
  • Authorization code interception — A stolen challenge_token is useless without the code_verifier, which never leaves the browser
  • Replay attacks — The challenge_token is invalidated after a single use
  • Cross-site request forgery (CSRF) — The server validates that the finalize request matches the original authorization request

Redirect to the provider

Use loginWithOAuth to redirect the user to the provider’s authorization page:
await client.loginWithOAuth({
  provider: "google",
  redirectURI: "https://yourapp.com/callback",
});
The redirectURI must match the redirect URI configured in your OAuth provider settings.

Handle the callback

When the provider redirects back to your app, extract the challenge_token from the URL and finalize the login:
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) {
  // The provider returned an error (user denied access, etc.)
  console.error(params.get("error_description") || error);
} else if (challengeToken) {
  try {
    const result = await client.finalizeOAuthLogin(challengeToken);
    if (result.status === "logged_in") {
      // User is now authenticated
    } else if (result.status === "otp_required") {
      // Provider returned an unverified email and the app has
      // verify_email enabled. The OTP has already been sent —
      // route to your OTP screen and call checkOTP with the code.
      // See the "Verify email via OTP" section below.
    }
  } catch (error) {
    if (error instanceof PrldErrors.ExpiredChallengeToken) {
      // Challenge token has expired — restart the flow
    } else if (error instanceof PrldErrors.InvalidChallengeToken) {
      // Challenge token is invalid
    } else if (error instanceof PrldErrors.BadRequest) {
      // Invalid request
    } else if (error instanceof PrldErrors.RateLimited) {
      // Too many attempts
    }
  }
}

Verify email via OTP

When the OAuth provider config has verify_email enabled and the IdP returns an unverified email, Session does not finalize the login on the callback. Instead, the redirect carries status=otp_required alongside the challenge_token, and finalizeOAuthLogin returns:
{ status: "otp_required"; challengeId: string; email: string }
The OTP has already been sent to email. Your app shows an OTP screen, the user enters the code, and you call checkOTP:
const result = await client.finalizeOAuthLogin(challengeToken);

if (result.status === "otp_required") {
  // result.email is the address the OTP was sent to
  // Show your OTP screen, then call:
  await client.checkOTP({ code: codeFromUser });
  // checkOTP verifies the OTP against the verification cookie
  // and finalizes the login internally — no challengeId needed.

  // Optional: resend the OTP
  // await client.retryOTP();
}
After checkOTP resolves successfully the user is fully logged in — call client.refresh() (or whatever your app uses to load the session) and proceed.
Replace src/App.jsx with:
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 [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 providers = ["google", "apple", "github", "microsoft", "okta", "facebook"];

  const handleLogin = async (provider) => {
    setError(null);
    try {
      await client.loginWithOAuth({
        provider,
        redirectURI: window.location.origin + window.location.pathname,
      });
    } catch (err) {
      if (err instanceof PrldErrors.BadRequest) {
        setError(`${provider} login is not configured.`);
      } else {
        setError("Something went wrong. 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>Social 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>}
            <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
              {providers.map((provider) => (
                <button key={provider} onClick={() => handleLogin(provider)}>
                  Sign in with {provider.charAt(0).toUpperCase() + provider.slice(1)}
                </button>
              ))}
            </div>
          </>
        )}
      </main>
    </>
  );
}