Skip to main content
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
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 {
    await client.finalizeOAuthLogin(challengeToken);
    // User is now authenticated
  } 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
    }
  }
}
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"];

  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>
    </>
  );
}