# Auth Applications Source: https://docs.prelude.so/auth/documentation/applications Learn how Prelude Auth applications work. ## Application Isolation In the Prelude Auth, all operations are scoped and isolated at the application level. This isolation ensures that data and operations for one application cannot affect or access data from another application, providing strong security boundaries between different implementations. To create a new application, [contact us](mailto:support@prelude.so) . ## Multiple Applications Each Prelude customer can have multiple applications, allowing you to: * Separate different environments (development, staging, production) * Isolate different products or services within your organization * Create dedicated applications for specific use cases ### Application IDs Each application is identified by a unique **Application ID** (`appID`), which serves as the namespace for all resources and operations: * **API Endpoints**: Most API paths include the appID (e.g., `/v2/session/apps/{appID}/users`) * **Webhooks**: Event notifications are configured per-application * **Data Storage**: All user data is partitioned by application ID ### Benefits * **Multi-tenancy**: Manage multiple environments with complete data separation * **Security**: Prevents cross-application data access * **Organization**: Clearly separates resources between different applications When using Prelude Auth, you'll need to use your assigned application ID consistently to ensure proper resource access. # Logged-in Change Password Source: https://docs.prelude.so/auth/documentation/change-password Let authenticated users change their password using direct step-up, without configuring a delegation hook. Once a user is authenticated, you can let them change their password from the account area without asking for their current password and without forcing them to sign out. Prelude protects the change password endpoint with the `prld:pwd:write` session scope — the user acquires the scope by completing a short verification challenge (SMS or email OTP), then calls the [change password](/session/api-reference/frontend/reset-password) endpoint. This guide shows how to wire that flow using a **direct** step-up scope configuration: you declare the challenge inline on the scope entry and Prelude serves it without calling any backend hook. ## How it works ```mermaid theme={null} sequenceDiagram autonumber actor U as User (logged in) participant SDK as Frontend SDK participant P as Prelude API U->>SDK: Click "Change password" SDK->>P: POST /v1/session/stepup/request (scope=prld:pwd:write) P-->>SDK: status=review, steps=[verify_sms | verify_email] loop Managed OTP step U->>SDK: Enter OTP code SDK->>P: startOTP / checkOTP (challengeId) P-->>SDK: step done end SDK->>P: refresh(step_up_token) P-->>SDK: access_token with prld:pwd:write U->>SDK: Submit new password SDK->>P: POST /v1/session/me/password/reset P-->>SDK: 204 No Content (scope consumed) ``` Because the step-up response is configured inline on the scope entry (`mode: "direct"`), there is **no need to run a delegation hook**: no `delegation_hook` is involved and `jwks_url` can be left empty as long as every `allowed_scopes` entry uses `direct` mode. ## Prerequisites * A Prelude account with access to the Auth API * An **Application ID** (`appID`) — see [Applications](/session/documentation/applications) * Your **Management API key** for backend calls * Password login configured on the application — see [Password Authentication](/session/documentation/integration-guide/password-authentication) * Users with an `email_address` or `phone_number` identifier (used for the OTP step) ## Configure the step-up flow Add `prld:pwd:write` to the application's allowed scopes so it can be requested from the frontend: ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/scopes \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "scope": "prld:pwd:write" }' ``` Create a step-up configuration that resolves `prld:pwd:write` directly. The `jwks_url` can stay empty because no delegated scope is configured. Because the step differs depending on the user's identifier (`verify_email` for email, `verify_sms` for phone), we register two `allowed_scopes` entries for the same scope, each scoped to a different identifier type. The first entry that matches one of the user's identifiers is used. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "jwks_url": "", "step_keys": [], "allowed_scopes": [ { "scope": "prld:pwd:write", "mode": "direct", "direct": { "identifier_types": ["email_address"], "status": "review", "granted_for": 300, "grant_mode": "session-bound", "steps": [ { "order": 1, "key": "verify_email", "expiration_duration": 600 } ] } }, { "scope": "prld:pwd:write", "mode": "direct", "direct": { "identifier_types": ["phone_number"], "status": "review", "granted_for": 300, "grant_mode": "session-bound", "steps": [ { "order": 1, "key": "verify_sms", "expiration_duration": 600 } ] } } ] }' ``` | Field | Description | | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `allowed_scopes[].scope` | Scope this entry resolves. | | `allowed_scopes[].mode` | `direct` to serve a static decision, `delegated` to call a delegation hook. | | `allowed_scopes[].direct.identifier_types` | Identifier types the user must hold for this entry to match. Valid values: `email_address`, `phone_number`. | | `allowed_scopes[].direct.status` / `grant_mode` / `granted_for` / `steps` | The decision fields, flattened. Same shape as the [step-up hook response](/session/documentation/step-up-hook#hook-response), inline on the scope entry. | Entries are matched in declaration order. The first `direct` entry whose `scope` matches **and** whose `identifier_types` overlaps with the user's identifiers wins. Put the preferred challenge at the top. You **can** list multiple identifier types in a single entry when the same decision applies to all of them (e.g. a custom step that is identifier-agnostic). Here we need two entries because the step differs: `verify_email` for email users, `verify_sms` for phone users. If you only support one identifier type, provide a single entry and list just that type. If no `direct` entry's `identifier_types` match the user, Prelude falls back to a `delegated` entry for the same scope when one exists. Add one — pointing at your delegation hook — if you want to keep the flow alive for users whose identifier type isn't covered by any `direct` entry. ## Relationship with login `granted_scopes` The step-up path described above is for users who are **already logged in** and want to change their password from the account area. It is complementary to the `granted_scopes` list on [OTP login configurations](/session/api-reference/management/config/login-otp/create-login-otp-config) and [social (OAuth) login configurations](/session/api-reference/management/config/login-oauth/create-login-oauth-config), which covers a different entry point: | Entry point | Mechanism | When to use | | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Unlogged change password / account creation | OTP or social login config that lists `prld:pwd:write` in `granted_scopes` attaches the scope to the session at login time | The user proves possession of the identifier via the login itself, so requiring another step-up right after would be redundant | | Logged-in change password | Static step-up on `prld:pwd:write` | The user is already authenticated and we want a fresh proof of possession before changing the password | Both can — and usually should — coexist on the same application. `granted_scopes` accepts any session scope, so the same mechanism can grant scopes other than `prld:pwd:write` at login time. ## Trigger the flow from the frontend Once the configuration above is in place, wire the flow into your frontend. See the [Web SDK Change Password guide](/session/documentation/frontend-sdks/web/change-password) for the full code example and a runnable **Try it** sandbox. ## What happens server-side 1. `POST /v1/session/stepup/request` with `scope=prld:pwd:write` looks up the app's step-up configuration. 2. Because `prld:pwd:write` has direct entries, Prelude picks the first entry whose `identifier_types` matches the user and returns its inline decision — no hook is invoked. 3. The user completes the OTP step. On completion Prelude mints a step-up token which the SDK uses to refresh the session; the new access token carries `prld:pwd:write`. 4. `POST /v1/session/me/password/reset` validates the scope, writes the new password, and **atomically removes the scope from the session** so it cannot be reused. If you later want to upgrade the flow with custom signals — for example to deny the change password for suspicious contexts — switch the entry's `mode` from `direct` to `delegated` and point it at a [delegation hook](/session/documentation/step-up-hook). Direct and delegated scopes can coexist in the same configuration. ## Constraints | Rule | Limit | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `jwks_url` | Required when at least one `allowed_scopes` entry uses `delegated` mode. Can be empty when every entry uses `direct` mode. | | `direct.identifier_types` | Must not be empty. Allowed values: `email_address`, `phone_number`. | | `(scope, identifier_type)` pairs (direct) | Must be unique across all `direct` entries. | | Delegated entries per scope | At most one. It is used as a fallback when no `direct` entry matches. | | Response `granted_for` | 0 to 86400 seconds. For a change password, keep it short (60–300 seconds is typical). | | Response `grant_mode` | Use `single-use` so the scope is attached to a single access token only. | ## What's next? Full overview of step-up, including dynamic decisions via the delegation hook. Response shape used by delegated scopes. The same fields are inlined on direct scope entries. # Domain Names Source: https://docs.prelude.so/auth/documentation/domain-names Learn about how to manage domain names for your application. For your auth application to work correctly and securely, you need to set up at least one custom domain name. After the set up, all requests to Prelude Auth will be routed through this domain name. ## Domain Name Requirements The custom domain name must be a valid domain name owned by you. It must be a subdomain of the domain name your application is hosted on. If you host your application on multiple domains, you can set up one for each domain. For example, if your application is hosted on `example.com`, you can set up a custom domain name for Prelude Auth like `session.example.com`. Prelude Auth uses [cookies](/auth/documentation/cookies) to authenticate requests. Modern web browsers block third-party cookies by default. By setting up a custom domain name that is a subdomain of the domain your application is hosted on, you can ensure that the cookies won't be blocked. ## How To Set Up a Custom Domain Name Perform a [`POST /v2/session/apps/{appID}/domains`](/auth/api-reference/management/domains/create-domain) to the Auth Management API. Ensure that the domain name is a subdomain of the domain your frontend application is hosted on. Retrieve the CNAME record from the `cname_record` field in the response and add it to your DNS settings for your domain name. This step varies depending on your DNS provider. Perform a [`POST /v2/session/apps/{appID}/domains/{domainID}/verify`](/auth/api-reference/management/domains/verify-domain) to the Auth Management API. Prelude will automatically try to verify the CNAME record, and issue an SSL certificate for your domain name. You are all set! 🎉 You can now use the custom domain name to access your application. # Change Password Source: https://docs.prelude.so/auth/documentation/frontend-sdks/mobile/change-password Implement logged-in change password with the Prelude mobile SDKs. Let an authenticated user change their password from the account area. The SDK acquires the `prld:pwd:write` session scope via a short OTP challenge, then calls the change-password endpoint — the scope is consumed atomically on save. See the [Logged-in Change Password guide](/session/documentation/change-password) for the backend configuration (direct step-up on `prld:pwd:write`). This page focuses on the mobile integration. ## Flow at a glance 1. Request the `prld:pwd:write` scope via `requestStepUp`. 2. Fire OTP delivery for the returned challenge via `sendStepUpOTP` — the user receives the code on the identifier the server picked (`verify_email` or `verify_sms`). 3. Submit the code via `submitStepUpOTP`. 4. Once `submitStepUpOTP` returns no further step, the SDK has refreshed the session with the granted scope. Call `client.changePassword`. `changePassword` consumes the scope atomically on save — the SDK invalidates the cached access token and runs a best-effort refresh so the next mint drops the now-spent scope. A leaked token therefore can't change the password again without re-stepping up. For the full step-up SDK surface, see [Step-Up](/session/documentation/frontend-sdks/mobile/step-up). ## Example ```swift theme={null} import PreludeAuth func changePassword( client: PreludeAuthClient, enteredCode: String, newPassword: String ) async throws { // 1. Acquire the scope. let challenge = try await client.requestStepUp(scope: "prld:pwd:write") guard challenge.status != .blocked else { throw PreludeAuthError.forbidden("Change password is not allowed") } // 2. Fire OTP delivery for the step the server picked. if challenge.currentStep == "verify_email" || challenge.currentStep == "verify_sms" { try await client.sendStepUpOTP(challenge) } // 3. Submit the code the user entered. In a real UI this is // a separate submit handler bound to the user's input. let next = try await client.submitStepUpOTP(challenge, code: enteredCode) if next != nil { // Multi-step flow — call sendStepUpOTP(next) for the next // delivery step, then submitStepUpOTP again, until next == nil. } // 4. The SDK refreshed the session for us; the access token // now carries `prld:pwd:write`. Save the new password. try await client.changePassword(RedactedString(newPassword)) } ``` ```kotlin theme={null} import so.prelude.android.auth.* suspend fun changePassword( client: PreludeAuthClient, enteredCode: String, newPassword: String, ) { // 1. Acquire the scope. val challenge = client.requestStepUp("prld:pwd:write") if (challenge.status == PreludeStepUpStatus.BLOCKED) { throw PreludeAuthError.Forbidden("Change password is not allowed") } // 2. Fire OTP delivery for the step the server picked. if (challenge.currentStep in setOf("verify_email", "verify_sms")) { client.sendStepUpOTP(challenge) } // 3. Submit the code the user entered. In a real UI this is // a separate submit handler bound to the user's input. val next = client.submitStepUpOTP(challenge, enteredCode) if (next != null) { // Multi-step flow — call sendStepUpOTP(next) for the next // delivery step, then submitStepUpOTP again, until next == null. } // 4. The SDK refreshed the session for us; the access token // now carries `prld:pwd:write`. Save the new password. client.changePassword(RedactedString(newPassword)) } ``` ```dart theme={null} import 'package:prelude_flutter_auth_sdk/prelude_flutter_auth_sdk.dart'; Future changePassword( PreludeAuthClient client, String enteredCode, String newPassword, ) async { // 1. Acquire the scope. final challenge = await client.requestStepUp(scope: 'prld:pwd:write'); if (challenge.status == StepUpStatus.blocked) { throw const ForbiddenException('Change password is not allowed'); } // 2. Fire OTP delivery for the step the server picked. if (challenge.currentStep == 'verify_email' || challenge.currentStep == 'verify_sms') { await client.sendStepUpOTP(challenge); } // 3. Submit the code the user entered. In a real UI this is // a separate submit handler bound to the user's input. final next = await client.submitStepUpOTP(challenge, enteredCode); if (next != null) { // Multi-step flow — call sendStepUpOTP(next) for the next // delivery step, then submitStepUpOTP again, until next == null. } // 4. The SDK refreshed the session for us; the access token // now carries `prld:pwd:write`. Save the new password. await client.changePassword(RedactedString(newPassword)); } ``` ```ts theme={null} import { ForbiddenError, PreludeAuthClient, RedactedString, } from "@prelude.so/react-native-auth-sdk"; async function changePassword( client: PreludeAuthClient, enteredCode: string, newPassword: string, ) { // 1. Acquire the scope. const challenge = await client.requestStepUp("prld:pwd:write"); if (challenge.status === "block") { throw new ForbiddenError("Change password is not allowed"); } // 2. Fire OTP delivery for the step the server picked. if ( challenge.currentStep === "verify_email" || challenge.currentStep === "verify_sms" ) { await client.sendStepUpOTP(challenge); } // 3. Submit the code the user entered. In a real UI this is // a separate submit handler bound to the user's input. const next = await client.submitStepUpOTP(challenge, enteredCode); if (next !== null) { // Multi-step flow — call sendStepUpOTP(next) for the next // delivery step, then submitStepUpOTP again, until next === null. } // 4. The SDK refreshed the session for us; the access token // now carries `prld:pwd:write`. Save the new password. await client.changePassword(new RedactedString(newPassword)); } ``` Builds on the project from [Introduction](/session/documentation/frontend-sdks/mobile/introduction) and a working password login ([Password](/session/documentation/frontend-sdks/mobile/password)). The user you log in with must have an `email_address` or `phone_number` identifier. **1. Configure direct step-up for `prld:pwd:write`** ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "jwks_url": "", "step_keys": [], "allowed_scopes": [ { "scope": "prld:pwd:write", "mode": "direct", "direct": { "identifier_types": ["email_address"], "status": "review", "granted_for": 300, "grant_mode": "single-use", "steps": [ { "order": 1, "key": "verify_email", "expiration_duration": 600 } ] } }, { "scope": "prld:pwd:write", "mode": "direct", "direct": { "identifier_types": ["phone_number"], "status": "review", "granted_for": 300, "grant_mode": "single-use", "steps": [ { "order": 1, "key": "verify_sms", "expiration_duration": 600 } ] } } ] }' ``` **2. Register the scope** ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/scopes \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "scope": "prld:pwd:write" }' ``` **3. Replace your entry file** Replace `ContentView.swift`: ```swift ContentView.swift theme={null} import SwiftUI import PreludeAuth private let appID = "YOUR_APP_ID" enum CPView { case login, logged, stepUpOTP, newPassword, done } @MainActor final class ChangePasswordModel: ObservableObject { let client = try! PreludeAuthClient( endpoint: .custom("https://\(appID).session.prelude.dev") ) @Published var view: CPView = .login @Published var error: String? var challenge: StepUpChallenge? func login(email: String, password: String) async { error = nil do { _ = try await client.loginWithPassword( LoginWithPasswordOptions(emailAddress: email, password: password) ) view = .logged } catch { self.error = error.localizedDescription } } func startChangePassword() async { error = nil do { let c = try await client.requestStepUp(scope: "prld:pwd:write") if c.status == .blocked { error = "Couldn't change your password — please try again later." return } if c.currentStep == "verify_email" || c.currentStep == "verify_sms" { try await client.sendStepUpOTP(c) } challenge = c view = .stepUpOTP } catch { self.error = error.localizedDescription } } func submitOTP(_ code: String) async { guard let c = challenge else { return } error = nil do { if let next = try await client.submitStepUpOTP(c, code: code) { challenge = next // unusual: more than one step } else { view = .newPassword // scope granted } } catch PreludeAuthError.invalidOTPCode { error = "Wrong code." } catch { self.error = error.localizedDescription } } func savePassword(_ newPassword: String) async { error = nil do { try await client.changePassword(RedactedString(newPassword)) view = .done } catch { self.error = error.localizedDescription } } } struct ContentView: View { @StateObject private var model = ChangePasswordModel() @State private var email = "" @State private var password = "" @State private var otp = "" @State private var newPassword = "" var body: some View { VStack(spacing: 12) { Text("Change Password Demo").font(.title3) if let error = model.error { Text(error).foregroundStyle(.red).font(.caption) } switch model.view { case .login: TextField("user@example.com", text: $email) .textInputAutocapitalization(.never) .keyboardType(.emailAddress) SecureField("Current password", text: $password) Button("Log in") { Task { await model.login(email: email, password: password) } }.buttonStyle(.borderedProminent) case .logged: Text("Logged in.") Button("Change password") { Task { await model.startChangePassword() } }.buttonStyle(.borderedProminent) case .stepUpOTP: Text("Enter the code we just sent you.") TextField("OTP code", text: $otp).keyboardType(.numberPad) Button("Verify") { Task { await model.submitOTP(otp) } }.buttonStyle(.borderedProminent) case .newPassword: Text("Choose a new password.") SecureField("New password", text: $newPassword) Button("Save") { Task { await model.savePassword(newPassword) } }.buttonStyle(.borderedProminent) case .done: Text("Password updated.").font(.headline) Text("`prld:pwd:write` has been consumed.") .font(.caption).foregroundStyle(.secondary) } } .textFieldStyle(.roundedBorder) .padding() } } ``` Replace `MainActivity.kt`: ```kotlin MainActivity.kt theme={null} package com.example.sessiontest import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardOptions import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.launch import so.prelude.android.auth.* import java.net.URL private const val APP_ID = "YOUR_APP_ID" enum class CpView { Login, Logged, StepUpOtp, NewPassword, Done } class ChangePasswordViewModel(ctx: android.content.Context) : ViewModel() { val client = PreludeAuthClient( context = ctx, baseUrl = URL("https://$APP_ID.session.prelude.dev"), ) var view by mutableStateOf(CpView.Login); private set var error by mutableStateOf(null); private set private var challenge: PreludeStepUpChallenge? = null fun login(email: String, password: String) { error = null viewModelScope.launch { try { client.loginWithPassword( LoginWithPasswordOptions(identifier = email, password = password), ) view = CpView.Logged } catch (e: PreludeAuthError) { error = e.message } } } fun startChangePassword() { error = null viewModelScope.launch { try { val c = client.requestStepUp("prld:pwd:write") if (c.status == PreludeStepUpStatus.BLOCKED) { error = "Couldn't change your password — please try again later." return@launch } if (c.currentStep in setOf("verify_email", "verify_sms")) { client.sendStepUpOTP(c) } challenge = c view = CpView.StepUpOtp } catch (e: PreludeAuthError) { error = e.message } } } fun submitOtp(code: String) { val c = challenge ?: return error = null viewModelScope.launch { try { val next = client.submitStepUpOTP(c, code) if (next != null) { challenge = next // unusual: more than one step } else { view = CpView.NewPassword // scope granted } } catch (e: PreludeAuthError.InvalidOTPCode) { error = "Wrong code." } catch (e: PreludeAuthError) { error = e.message } } } fun savePassword(newPassword: String) { error = null viewModelScope.launch { try { client.changePassword(RedactedString(newPassword)) view = CpView.Done } catch (e: PreludeAuthError) { error = e.message } } } } class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MaterialTheme { Surface { val vm: ChangePasswordViewModel = viewModel(factory = viewModelFactory(applicationContext)) var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var otp by remember { mutableStateOf("") } var newPassword by remember { mutableStateOf("") } Column(Modifier.fillMaxSize().padding(24.dp)) { Text("Change Password Demo", style = MaterialTheme.typography.titleMedium) Spacer(Modifier.height(12.dp)) vm.error?.let { Text(it, color = MaterialTheme.colorScheme.error) Spacer(Modifier.height(8.dp)) } when (vm.view) { CpView.Login -> { OutlinedTextField(email, { email = it }, label = { Text("user@example.com") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), modifier = Modifier.fillMaxWidth()) Spacer(Modifier.height(8.dp)) OutlinedTextField(password, { password = it }, label = { Text("Current password") }, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) Spacer(Modifier.height(12.dp)) Button(onClick = { vm.login(email, password) }) { Text("Log in") } } CpView.Logged -> { Text("Logged in.") Spacer(Modifier.height(8.dp)) Button(onClick = { vm.startChangePassword() }) { Text("Change password") } } CpView.StepUpOtp -> { Text("Enter the code we just sent you.") OutlinedTextField(otp, { otp = it }, label = { Text("OTP code") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth()) Spacer(Modifier.height(8.dp)) Button(onClick = { vm.submitOtp(otp) }) { Text("Verify") } } CpView.NewPassword -> { Text("Choose a new password.") OutlinedTextField(newPassword, { newPassword = it }, label = { Text("New password") }, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) Spacer(Modifier.height(8.dp)) Button(onClick = { vm.savePassword(newPassword) }) { Text("Save") } } CpView.Done -> { Text("Password updated.", style = MaterialTheme.typography.titleSmall) Text("`prld:pwd:write` has been consumed.", style = MaterialTheme.typography.bodySmall) } } } } } } } } ``` (`viewModelFactory` is the small helper defined in [Introduction](/session/documentation/frontend-sdks/mobile/introduction#helpers).) Replace `lib/main.dart`: ```dart lib/main.dart theme={null} import 'package:flutter/material.dart'; import 'package:prelude_flutter_auth_sdk/prelude_flutter_auth_sdk.dart'; const appID = 'YOUR_APP_ID'; void main() => runApp(const MyApp()); enum _Screen { login, logged, stepUpOtp, newPassword, done } class MyApp extends StatefulWidget { const MyApp({super.key}); @override State createState() => _MyAppState(); } class _MyAppState extends State { late final client = PreludeAuthClient( endpoint: Endpoint.custom('https://$appID.session.prelude.dev'), ); final _email = TextEditingController(); final _password = TextEditingController(); final _otp = TextEditingController(); final _newPassword = TextEditingController(); _Screen _view = _Screen.login; StepUpChallenge? _challenge; String? _error; Future _login() async { setState(() => _error = null); try { await client.loginWithPassword( LoginWithPasswordOptions( emailAddress: _email.text, password: _password.text, ), ); setState(() => _view = _Screen.logged); } on PreludeAuthException catch (e) { setState(() => _error = e.message); } } Future _startChangePassword() async { setState(() => _error = null); try { final c = await client.requestStepUp(scope: 'prld:pwd:write'); if (c.status == StepUpStatus.blocked) { setState(() => _error = 'Couldn\'t change your password — please try again later.'); return; } if (c.currentStep == 'verify_email' || c.currentStep == 'verify_sms') { await client.sendStepUpOTP(c); } setState(() { _challenge = c; _view = _Screen.stepUpOtp; }); } on PreludeAuthException catch (e) { setState(() => _error = e.message); } } Future _submitOtp() async { final c = _challenge; if (c == null) return; setState(() => _error = null); try { final next = await client.submitStepUpOTP(c, _otp.text); if (next != null) { setState(() => _challenge = next); // unusual: more than one step } else { setState(() => _view = _Screen.newPassword); // scope granted } } on InvalidOTPCodeException { setState(() => _error = 'Wrong code.'); } on PreludeAuthException catch (e) { setState(() => _error = e.message); } } Future _savePassword() async { setState(() => _error = null); try { await client.changePassword(RedactedString(_newPassword.text)); setState(() => _view = _Screen.done); } on PreludeAuthException catch (e) { setState(() => _error = e.message); } } @override void dispose() { _email.dispose(); _password.dispose(); _otp.dispose(); _newPassword.dispose(); client.dispose(); super.dispose(); } @override Widget build(BuildContext context) { Widget body; switch (_view) { case _Screen.login: body = Column(mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _email, keyboardType: TextInputType.emailAddress, decoration: const InputDecoration(labelText: 'user@example.com'), ), const SizedBox(height: 8), TextField( controller: _password, obscureText: true, decoration: const InputDecoration(labelText: 'Current password'), ), const SizedBox(height: 12), FilledButton(onPressed: _login, child: const Text('Log in')), ]); case _Screen.logged: body = Column(mainAxisSize: MainAxisSize.min, children: [ const Text('Logged in.'), const SizedBox(height: 12), FilledButton( onPressed: _startChangePassword, child: const Text('Change password'), ), ]); case _Screen.stepUpOtp: body = Column(mainAxisSize: MainAxisSize.min, children: [ const Text('Enter the code we just sent you.'), TextField( controller: _otp, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'OTP code'), ), const SizedBox(height: 12), FilledButton(onPressed: _submitOtp, child: const Text('Verify')), ]); case _Screen.newPassword: body = Column(mainAxisSize: MainAxisSize.min, children: [ const Text('Choose a new password.'), TextField( controller: _newPassword, obscureText: true, decoration: const InputDecoration(labelText: 'New password'), ), const SizedBox(height: 12), FilledButton(onPressed: _savePassword, child: const Text('Save')), ]); case _Screen.done: body = Column(mainAxisSize: MainAxisSize.min, children: const [ Text('Password updated.', style: TextStyle(fontSize: 18)), SizedBox(height: 8), Text('`prld:pwd:write` has been consumed.', style: TextStyle(color: Colors.grey)), ]); } return MaterialApp( home: Scaffold( body: Padding( padding: const EdgeInsets.all(24), child: Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ const Text('Change Password Demo', style: TextStyle(fontSize: 18)), const SizedBox(height: 12), if (_error != null) ...[ Text(_error!, style: const TextStyle(color: Colors.red)), const SizedBox(height: 8), ], body, ]), ), ), ), ); } } ``` Replace `app/index.tsx`: ```tsx app/index.tsx theme={null} import { Endpoint, InvalidOTPCodeError, PreludeAuthClient, PreludeAuthError, RedactedString, StepUpChallenge, } from "@prelude.so/react-native-auth-sdk"; import { useEffect, useRef, useState } from "react"; import { Button, Text, TextInput, View } from "react-native"; const APP_ID = "YOUR_APP_ID"; type Stage = "login" | "logged" | "stepUpOtp" | "newPassword" | "done"; export default function Home() { const clientRef = useRef(null); const [stage, setStage] = useState("login"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [otp, setOtp] = useState(""); const [newPassword, setNewPassword] = useState(""); const challengeRef = useRef(null); const [error, setError] = useState(null); useEffect(() => { const c = new PreludeAuthClient({ endpoint: Endpoint.custom(`https://${APP_ID}.session.prelude.dev`), }); clientRef.current = c; return () => { c.dispose().catch(() => {}); }; }, []); async function run(fn: () => Promise) { setError(null); try { await fn(); } catch (e) { if (e instanceof InvalidOTPCodeError) setError("Wrong code."); else if (e instanceof PreludeAuthError) setError(e.message); else setError(String(e)); } } const login = () => run(async () => { await clientRef.current!.loginWithPassword({ emailAddress: email, password: new RedactedString(password), }); setStage("logged"); }); const startChangePassword = () => run(async () => { const c = await clientRef.current!.requestStepUp("prld:pwd:write"); if (c.status === "block") { setError("Couldn't change your password — please try again later."); return; } if (c.currentStep === "verify_email" || c.currentStep === "verify_sms") { await clientRef.current!.sendStepUpOTP(c); } challengeRef.current = c; setStage("stepUpOtp"); }); const submitOtp = () => run(async () => { const c = challengeRef.current; if (!c) return; const next = await clientRef.current!.submitStepUpOTP(c, otp); if (next !== null) { if (next.currentStep === "verify_email" || next.currentStep === "verify_sms") { await clientRef.current!.sendStepUpOTP(next); } challengeRef.current = next; } else { setStage("newPassword"); } }); const savePassword = () => run(async () => { await clientRef.current!.changePassword(new RedactedString(newPassword)); setStage("done"); }); return ( Change Password Demo {error && {error}} {stage === "login" && ( <> )} {view === "logged" && (

Logged in.

)} {view === "stepup-otp" && (

Enter the verification code we just sent you.

setOtpCode(e.target.value)} required />
)} {view === "new-password" && (

Choose a new password.

setNewPassword(e.target.value)} required />
)} {view === "done" && (

Password updated. The `prld:pwd:write` scope has been consumed.

)} ); } ``` Run `npm run dev`, log in, then click **Change password**. You'll receive an OTP on the identifier type configured above. After verification, enter a new password — the `prld:pwd:write` scope is consumed atomically on save and removed from the session.
# Enterprise SSO Source: https://docs.prelude.so/auth/documentation/frontend-sdks/web/enterprise-oidc Implement enterprise SSO (OIDC) login with the Prelude JavaScript SDK. This guide covers how to start an enterprise single sign-on (OIDC) login from your web application. Make sure you have [configured an enterprise connection](/session/documentation/integration-guide/enterprise-oidc/introduction) on your backend before proceeding. ## Enterprise login flow An enterprise SSO login is the same three-step flow as [social login](/session/documentation/frontend-sdks/web/social-login): 1. **Redirect** — Your app asks the SDK to start the flow; it redirects the user to the Identity Provider. 2. **Callback** — After authenticating, the IdP redirects back and Prelude redirects to your app with a `challenge_token`. 3. **Finalize** — Your app sends the `challenge_token` to complete authentication. The `challenge_token` is finalized exactly like social login — via `finalizeOAuthLogin`. Unlike personal social login, an enterprise connection is bound to email domains, so it never returns the `otp_required` step: it always resolves to `{ status: "logged_in" }`. The flow is protected by [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) (Proof Key for Code Exchange). The SDK generates a unique `code_verifier`/`code_challenge` pair for each login and stores the verifier locally; the `challenge_token` can only be finalized once, by the browser that started the flow. In addition, the connection the flow launched with is pinned server-side, and the IdP-asserted email's domain must fall within that connection's allowlist. ## Redirect to the Identity Provider There are two ways to start the flow. ### By email Resolve the connection from the user's email domain (it must match exactly one enabled connection's allowlist): ```javascript theme={null} await client.loginWithEnterpriseOAuth({ email: "jane@acme.com", redirectURI: "https://yourapp.com/callback", }); ``` ### By connection When you already know the connection, pass its `connectionId`: ```javascript theme={null} await client.loginWithEnterpriseOAuth({ connectionId: "ocon_01jqebhswje1ka1z7ahr9rfsgt", 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. The provider (Okta, Google, Microsoft) is derived server-side from the connection, so you never pass it here. Both forms navigate the browser to the IdP. If you need the authorization URL without navigating (e.g. to open it yourself), call `initiateEnterpriseOAuthLogin`, which returns `{ authorization_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 social login and SAML: ```javascript theme={null} 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) { // Enterprise validation or provisioning failure, e.g. // oauth_email_domain_not_allowed, oauth_user_not_provisioned. console.error(params.get("error_description") || error); } else if (challengeToken) { try { await client.finalizeOAuthLogin(challengeToken); // The user is now authenticated. Enterprise 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: ```jsx src/App.jsx theme={null} 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 handleEnterpriseLogin = async () => { setError(null); try { await client.loginWithEnterpriseOAuth({ email, redirectURI: window.location.origin + window.location.pathname, }); } catch (err) { // The initiate endpoint rejects an unknown email domain // (oauth_no_connection_for_email). setError("No SSO connection is configured for that email domain."); } }; return ( <>

Enterprise SSO

{loading ? (

Completing login...

) : user ? (

Logged in

                {JSON.stringify(decode(user.accessToken).claims, null, 2)}
              
) : ( <> {error &&

{error}

} setEmail(e.target.value)} /> )}
); } ```
## What's next? Set up an enterprise OIDC connection on the backend. Add classic social-login buttons. # Introduction Source: https://docs.prelude.so/auth/documentation/frontend-sdks/web/introduction Integrate the Prelude Auth SDK into your web application. This guide covers how to use the Prelude JavaScript SDK to authenticate users from a web frontend. ## Prerequisites Before you start, make sure you have: * A Prelude account with access to Prelude Auth — [contact us](mailto:support@prelude.so) to obtain access * An **Application ID** (`appID`) — see [Applications](/auth/documentation/applications) * An authentication method configured — see [Password Authentication](/auth/documentation/integration-guide/password-authentication) ## Install the SDK Install the Prelude JavaScript SDK in your frontend project: ```bash npm theme={null} npm install @prelude.so/js-sdk ``` ```bash yarn theme={null} yarn add @prelude.so/js-sdk ``` ```bash pnpm theme={null} pnpm add @prelude.so/js-sdk ``` ## Initialize the SDK Create a `PrldSessionClient` instance with your custom domain: ```javascript theme={null} import { PrldSessionClient } from "@prelude.so/js-sdk"; const client = new PrldSessionClient({ domain: "{app_id}.session.prelude.dev", }); ``` Scaffold a React project you will use for all the steps below: ```bash theme={null} npm create vite@latest session-test -- --template react cd session-test npm install @prelude.so/js-sdk @picocss/pico ``` Create a `.env` file at the root of the project with your Application ID: ```bash .env theme={null} VITE_APP_ID=your_app_id ``` Replace `src/App.jsx` with: ```jsx src/App.jsx theme={null} import "@picocss/pico"; import { PrldSessionClient } from "@prelude.so/js-sdk"; const client = new PrldSessionClient({ domain: `${import.meta.env.VITE_APP_ID}.session.prelude.dev`, }); export default function App() { return ( <>

Session Test

SDK initialized.

); } ``` Add `optimizeDeps` on vite configuration: ```js vite.config.js theme={null} import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], optimizeDeps: { exclude: ["@prelude.so/js-sdk"], include: ["@prelude.so/js-sdk > browser-tabs-lock"], }, }) ``` ```bash theme={null} npm run dev ```
# OTP Login Source: https://docs.prelude.so/auth/documentation/frontend-sdks/web/otp Implement OTP-based authentication with the Prelude JavaScript SDK. ## Start an OTP login Send a one-time password to a phone number: ```javascript theme={null} await client.startOTP({ identifier: { type: "phone_number", value: "+14155551234", }, }); ``` If your application is [configured for email OTP](/session/documentation/integration-guide/otp-login), you can use `email_address` instead of `phone_number` to send the code via email: ```javascript theme={null} await client.startOTP({ identifier: { type: "email_address", value: "user@example.com", }, }); ``` The SDK sends the OTP and persists the verification token (returned in the `X-Verification-Token` response header) along with a PKCE code verifier in `localStorage`. The user can then enter the code to complete the login. ## Check the OTP code Verify the code entered by the user: ```javascript theme={null} import { PrldErrors } from "@prelude.so/js-sdk"; try { await client.checkOTP({ code: "123456" }); // User is now authenticated } catch (error) { if (error instanceof PrldErrors.BadCheckCode) { // Wrong code entered } else if (error instanceof PrldErrors.Unauthorized) { // Verification expired or invalid } else if (error instanceof PrldErrors.BadRequest) { // Invalid request } else if (error instanceof PrldErrors.RateLimited) { // Too many attempts } } ``` On success, the API sets a [refresh cookie](/session/documentation/cookies) on the client. ## Retry sending the OTP If the user didn't receive the code, retry sending it: ```javascript theme={null} await client.retryOTP(); ``` Replace `src/App.jsx` with: ```jsx src/App.jsx theme={null} import "@picocss/pico"; import { useState } 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 [phone, setPhone] = useState(""); const [code, setCode] = useState(""); const [step, setStep] = useState("phone"); // "phone" | "code" | "done" const [user, setUser] = useState(null); const [error, setError] = useState(null); const handleSendOTP = async (e) => { e.preventDefault(); setError(null); try { await client.startOTP({ identifier: { type: "phone_number", value: phone }, }); setStep("code"); } catch (err) { if (err instanceof PrldErrors.BadRequest) { setError("Invalid phone number."); } else { setError("Something went wrong. Please try again."); } } }; const handleCheckOTP = async (e) => { e.preventDefault(); setError(null); try { await client.checkOTP({ code }); const { user } = await client.refresh(); setUser(user); setStep("done"); } catch (err) { if (err instanceof PrldErrors.BadCheckCode) { setError("Wrong code. Please try again."); } else if (err instanceof PrldErrors.Unauthorized) { setError("Verification expired. Please start over."); } else if (err instanceof PrldErrors.RateLimited) { setError("Too many attempts. Please try again later."); } else { setError("Something went wrong. Please try again."); } } }; const handleRetry = async () => { setError(null); try { await client.retryOTP(); } catch (err) { setError("Could not resend code. Please try again."); } }; return ( <>

OTP Login

{step === "done" && user ? (

Logged in

                {JSON.stringify(decode(user.accessToken).claims, null, 2)}
              
) : step === "code" ? (
setCode(e.target.value)} required /> {error &&

{error}

}
) : (
setPhone(e.target.value)} required /> {error &&

{error}

}
)}
); } ```
# Passkey Source: https://docs.prelude.so/auth/documentation/frontend-sdks/web/passkey Implement passkey (WebAuthn) authentication with the Prelude JavaScript SDK. Passkeys serve two roles on the SDK: * **MFA step-up factor** — the user already has an authenticated session and proves a passkey to acquire a sensitive scope. Driven by `continueWithPasskey`. * **Primary-factor sign-in** — the user signs in with just a passkey, no email/phone OTP, no password. Driven by `loginWithPasskey`. Requires `PasskeyConfig.login_enabled` server-side. For the full conceptual reference — ceremony shape, security model, error catalogue — see the [Passkey](/auth/documentation/passkey) page. For the backend setup, see the [Passkey integration guide](/auth/documentation/integration-guide/passkey). ## Detect WebAuthn support Gate the UI on the `isPasskeySupported()` helper. Returns `false` on browsers without a usable WebAuthn implementation (older Safari, headless contexts, some embedded webviews). ```javascript theme={null} import { isPasskeySupported } from "@prelude.so/js-sdk"; if (!isPasskeySupported()) { // Skip passkey entirely; offer SMS / email / password instead. } ``` ## Register a passkey Registration runs inside an authenticated session and consumes the `prld:passkey:write` scope, typically obtained via a step-up challenge just before enrolment so adding an authenticator always requires an additional ownership proof. ```javascript theme={null} import { PrldErrors } from "@prelude.so/js-sdk"; try { const { credential, alreadyRegistered } = await client.registerPasskey({ username: "user@example.com", // shown by the authenticator displayName: "User", // optional, defaults to username nickname: "MacBook", // optional, server-side label }); if (alreadyRegistered) { // The same authenticator was already registered for this user — no-op success. } } catch (error) { if (error instanceof PrldErrors.PasskeyNotSupported) { // Browser does not expose WebAuthn } else if (error instanceof PrldErrors.PasskeyNotConfigured) { // The app has no PasskeyConfig } else if (error instanceof PrldErrors.InsufficientScope) { // Session does not hold prld:passkey:write — drive a step-up first. } else if (error instanceof PrldErrors.PasskeyRegistrationFailed) { // Bad challenge, mismatched origin, excluded credential, or an // authenticator that refused to create a discoverable credential // when login_enabled is on. } else if (error instanceof PrldErrors.RateLimited) { // Too many begin ceremonies in the window } } ``` | Field | Required | Description | | ------------- | -------- | --------------------------------------------------------------------------------------------- | | `username` | Yes | WebAuthn `user.name` shown by the authenticator. Typically the user's primary email or phone. | | `displayName` | No | WebAuthn `user.displayName` shown alongside the name. Falls back to `username`. | | `nickname` | No | Server-side-only label for the "Manage your passkeys" UI ("MacBook", "YubiKey 5C"). | A user may register multiple credentials. The registration ceremony pre-populates `excludeCredentials` so the same authenticator can't be registered twice — duplicate registration is the idempotent `alreadyRegistered: true` path. The SDK invalidates its cached session and refreshes after a successful enrolment, so the next access token reflects the consumed scope and the (optionally mapped) `has_passkey` claim. ## Sign in with a passkey (primary factor) `loginWithPasskey` opens a WebAuthn ceremony with empty `allowCredentials` so the browser surfaces every discoverable credential it holds for the configured RPID. The server resolves the user from the assertion's `userHandle` — no identifier is sent from the client. Requires `PasskeyConfig.login_enabled` server-side. While the flag is on, registration also requests `residentKey: required` so the credential is discoverable. ```javascript theme={null} import { PrldErrors } from "@prelude.so/js-sdk"; try { await client.loginWithPasskey(); // User is now authenticated; the session was minted with login_method: "passkey". } catch (error) { if (error instanceof PrldErrors.PasskeyNotSupported) { // No WebAuthn in this browser } else if (error instanceof PrldErrors.PasskeyNotConfigured) { // login_enabled is false server-side } else if (error instanceof PrldErrors.Unauthorized) { // No matching passkey, or the assertion failed verification. // The server deliberately collapses unknown user / bad signature // into a single 401 so attackers cannot probe valid users. } else if (error instanceof PrldErrors.RateLimited) { // Too many begin ceremonies on the app } } ``` ### Conditional UI (autofill) Pass `mediation: "conditional"` to surface matching credentials in the username-field autofill chip instead of a modal authenticator picker. Pair with an `AbortSignal` so the SDK call cancels cleanly when the user picks a different sign-in method. ```javascript theme={null} const controller = new AbortController(); // Wire the controller to your "Sign in with password" button click handler // so it aborts the conditional request if the user picks a different method. try { await client.loginWithPasskey({ mediation: "conditional", signal: controller.signal, }); } catch (error) { if (error.name === "AbortError") { // User picked a different method — expected } } ``` ## Complete a verify\_passkey step-up step When `requestStepUp` returns a challenge whose first step is `verify_passkey`, complete it with `continueWithPasskey`. The SDK caches the assertion options under the challenge id, so the call is parameter-light: ```javascript theme={null} import { PrldErrors } from "@prelude.so/js-sdk"; let challengeId; await client.requestStepUp({ scope: "transfer:write", onChallenge: (info) => { challengeId = info.challengeId; if (info.currentStep !== "verify_passkey") { // Fall back to OTP / custom step UI. } }, }); if (challengeId) { try { await client.continueWithPasskey({ challengeId }); // Session is refreshed; the access token now carries "transfer:write". } catch (error) { if (error instanceof PrldErrors.PasskeyStepUnavailable) { // No registered credentials, assertion failed, or the cached // challenge doesn't have a verify_passkey step. Route to a fallback. } } } ``` `continueWithPasskey` runs `navigator.credentials.get()` against the cached options, posts the assertion to `/stepup/continue`, and the SDK refreshes the session automatically. ## Manage registered passkeys A "Manage your passkeys" UI uses three endpoints under `/me/passkeys`. ### List ```javascript theme={null} const passkeys = await client.listPasskeys(); // [ // { // credential_id: "XKv4eJk7mGmJYI4r-hZxxBg", // nickname: "MacBook", // transports: ["internal", "hybrid"], // backup_state: true, // created_at: 1717689600, // last_used_at: 1718901234, // }, // ... // ] ``` Returns an empty array when the user has none — not a 404 — so a settings page can render without special-casing. ### Rename ```javascript theme={null} await client.renamePasskey(credential.credential_id, "iPad"); ``` Renaming the label requires `prld:passkey:write`, the same scope as registration — drive a step-up first if the session doesn't hold it. Pass an empty string to clear the label. ### Delete Deletion requires `prld:passkey:write` — removing an authenticator is a sensitive operation, so it needs the same fresh step-up as registration rather than relying on the ambient session. ```javascript theme={null} import { PrldErrors } from "@prelude.so/js-sdk"; try { await client.deletePasskey(credential.credential_id); } catch (error) { if (error instanceof PrldErrors.InsufficientScope) { // Session does not hold prld:passkey:write — drive a step-up first. } else if (error instanceof PrldErrors.NotFound) { // Credential id doesn't match anything for this user } } ``` The SDK invalidates the cached session and refreshes after a successful delete since removing a credential can flip the `has_passkey` custom claim. A passkey is **never the first thing a user sets up** — registration runs inside an authenticated session and consumes the `prld:passkey:write` scope. So a working demo needs two things the snippets above leave out: * **A primary login method** to establish the session before any passkey exists. Here we use email OTP. The same first screen also offers `loginWithPasskey`, so a returning user can sign in with a passkey directly instead of email — on a fresh account that button has nothing to match yet, which is exactly why the email path is needed to bootstrap the first credential. * **A step-up to obtain `prld:passkey:write` before enrolment.** After login the enrollment screen has two explicit buttons: **Grant scope** runs `requestStepUp({ scope: "prld:passkey:write" })`, and **Register a passkey** then calls `registerPasskey`. To keep the demo short we configure the scope in `direct` mode with `status: "continue"` and `grant_mode: "session-bound"`, so the grant is immediate, requires no extra OTP challenge, and stays on the session — grant once, then register as many times as you like. In production, gate it behind a real `verify_email` / `verify_sms` / `verify_passkey` step so adding an authenticator always requires a fresh ownership proof. **1. Register the scope** ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/scopes \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "scope": "prld:passkey:write" }' ``` **2. Configure a direct step-up for `prld:passkey:write`** The step-up resolves inline (`mode: "direct"`), so no delegation hook is needed and `jwks_url` can stay empty. `status: "continue"` grants the scope immediately with no steps. (For a real ownership proof, switch to `status: "review"` and add a `steps` entry — see [Change Password](/auth/documentation/frontend-sdks/web/change-password) for the OTP-gated shape.) ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "jwks_url": "", "step_keys": [], "allowed_scopes": [ { "scope": "prld:passkey:write", "mode": "direct", "direct": { "identifier_types": ["email_address", "phone_number"], "status": "continue", "grant_mode": "session-bound", "granted_for": 600 } } ] }' ``` **3. Configure the PasskeyConfig** Point the Relying Party at your dev origin and set `login_enabled: true` so the "Sign in with a passkey" button works. `allowed_origins` must list **your localhost** (scheme + host + port) — here the Vite dev server on `http://localhost:5173`; with `rp_id: "localhost"`, adjust the port to match wherever `npm run dev` serves. ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/passkey \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "rp_id": "localhost", "rp_name": "Prelude Playground (local)", "allowed_origins": ["http://localhost:5173"], "user_verification": "required", "attestation_preference": "none", "login_enabled": true }' ``` Without `login_enabled: true`, `loginWithPasskey` returns `PasskeyNotConfigured` and only the email path works. **4. Replace `src/App.jsx`** ```jsx src/App.jsx theme={null} import "@picocss/pico"; import { useState } from "react"; import { PrldSessionClient, PrldErrors, isPasskeySupported } from "@prelude.so/js-sdk"; const client = new PrldSessionClient({ domain: `${import.meta.env.VITE_APP_ID}.session.prelude.dev` }); export default function App() { const [view, setView] = useState("login"); // login | code | enroll const [email, setEmail] = useState(""); const [code, setCode] = useState(""); const [scoped, setScoped] = useState(false); const [registered, setRegistered] = useState(false); const [error, setError] = useState(null); // ── Login: email OTP ──────────────────────────────────────────── const handleSendOTP = async (e) => { e.preventDefault(); setError(null); try { await client.startOTP({ identifier: { type: "email_address", value: email } }); setView("code"); } catch (err) { setError(err.message); } }; const handleCheckLogin = async (e) => { e.preventDefault(); setError(null); try { await client.checkOTP({ code }); setView("enroll"); } catch (err) { if (err instanceof PrldErrors.BadCheckCode) setError("Wrong code."); else setError(err.message); } }; // ── Login: passkey as a primary factor ────────────────────────── const handleLoginPasskey = async () => { setError(null); try { await client.loginWithPasskey(); setView("enroll"); } catch (err) { if (err instanceof PrldErrors.Unauthorized) { setError("No passkey matched — sign in with email to enroll your first one."); } else { setError(err.message); } } }; // ── Step 1: grant the scope (direct step-up, immediate) ───────── const handleGrantScope = async () => { setError(null); try { const { status } = await client.requestStepUp({ scope: "prld:passkey:write" }); if (status === "continue") setScoped(true); // session refreshed with the scope else if (status === "block") setError("Scope denied by configuration."); } catch (err) { setError(err.message); } }; // ── Step 2: register, now that the session holds the scope ────── const handleRegister = async () => { setError(null); try { await client.registerPasskey({ username: email || "user@example.com" }); setRegistered(true); // session-bound scope persists — can register again } catch (err) { if (err instanceof PrldErrors.InsufficientScope) { setError("Grant prld:passkey:write first."); } else { setError(err.message); } } }; // ── Render ────────────────────────────────────────────────────── return ( <>

Passkeys

{!isPasskeySupported() &&

WebAuthn not supported in this browser.

} {error &&

{error}

} {view === "login" && ( <>
setEmail(e.target.value)} required />
)} {view === "code" && (
setCode(e.target.value)} required />
)} {view === "enroll" && (

{scoped && !registered &&

Scope granted — you can register now.

} {registered &&

Passkey registered — refresh to log in with passkey.

}
)}
); } ``` Run `npm run dev`, sign in with your email, then click **Grant prld:passkey:write** (the `continue` config grants it instantly) followed by **Register a passkey** to enroll the credential. Once you have a passkey, reload and use **Sign in with a passkey** to log in without email.
## Error catalogue | Class | Triggered by | Typical recovery | | -------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------- | | `PrldErrors.PasskeyNotSupported` | Browser has no WebAuthn implementation | Fall back to a different sign-in method | | `PrldErrors.PasskeyNotConfigured` | App has no `PasskeyConfig`, or `login_enabled: false` on the login endpoints | Configure the Relying Party / flip the flag | | `PrldErrors.PasskeyRegistrationFailed` | The attestation failed verification | Retry, or route to a different authenticator | | `PrldErrors.PasskeyStepUnavailable` | No credentials, signature mismatch, or sign-count regression on a step-up assertion | Fall back to an OTP step | | `PrldErrors.Unauthorized` | `loginWithPasskey` — unknown user or bad assertion (single 401 by design) | Prompt the user to try again or pick a different method | | `PrldErrors.InsufficientScope` | `registerPasskey` / `renamePasskey` / `deletePasskey` without `prld:passkey:write` | Drive a step-up to grant the scope, then retry | | `PrldErrors.RateLimited` | Too many begin ceremonies | Honor `Retry-After` and back off | | `PrldErrors.NotFound` | `renamePasskey` / `deletePasskey` with an unknown credential id | Reload the list | ## What's next? * [Step-Up Authentication](/auth/documentation/frontend-sdks/web/step-up) — the SDK surface for `requestStepUp` and how `continueWithPasskey` plugs into it. * [Passkey reference](/auth/documentation/passkey) — full ceremony walk-through, security model, AAGUID policy, webhook events. * [Passkey integration guide](/auth/documentation/integration-guide/passkey) — backend curl flow to configure the Relying Party identity, step-up, and (optional) passwordless / enterprise policy. # Password Source: https://docs.prelude.so/auth/documentation/frontend-sdks/web/password Implement password-based authentication with the Prelude JavaScript SDK. ## Log in a user ```javascript theme={null} import { PrldErrors } from "@prelude.so/js-sdk"; try { await client.loginWithPassword({ identifier: "user@example.com", password: "SecureP@ssw0rd!", }); // User is now authenticated } catch (error) { if (error instanceof PrldErrors.Unauthorized) { // Invalid credentials } else if (error instanceof PrldErrors.BadRequest) { // Invalid email format } else if (error instanceof PrldErrors.RateLimited) { // Too many login attempts } } ``` Replace `src/App.jsx` with: ```jsx src/App.jsx theme={null} import "@picocss/pico"; import { useState } 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 [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [user, setUser] = useState(null); const [error, setError] = useState(null); const handleLogin = async (e) => { e.preventDefault(); setError(null); try { await client.loginWithPassword({ identifier: email, password }); const { user } = await client.refresh(); setUser(user); } catch (err) { if (err instanceof PrldErrors.Unauthorized) { setError("Invalid email or password."); } else if (err instanceof PrldErrors.InvalidPassword) { setError("Password does not meet the compliancy rules."); } else if (err instanceof PrldErrors.BadRequest) { setError("Invalid email format."); } else if (err instanceof PrldErrors.RateLimited) { setError("Too many attempts. Please try again later."); } else { setError("Something went wrong. Please try again."); } } }; return ( <>

Log In

{user ? (

Logged in

                {JSON.stringify(decode(user.accessToken).claims, null, 2)}
              
) : (
setEmail(e.target.value)} required /> setPassword(e.target.value)} required /> {error &&

{error}

}
)}
); } ```
## Validate password compliancy (optional) Password compliancy rules define the requirements a password must meet, such as minimum length, and required uppercase, lowercase, number, or symbol characters. These rules are configured on your application and enforced both client-side and server-side. Before submitting a sign-up form, validate the password against your compliancy rules. The SDK fetches the rules from the server and checks the password locally: ```javascript theme={null} const { valid, results } = await client.validatePassword("SecureP@ssw0rd!"); if (!valid) { // Show which criteria are not met for (const result of results) { if (!result.valid) { console.log(`${result.criteria}: expected ${result.expected}, got ${result.actual}`); } } } ``` You can also retrieve the raw compliancy configuration to build your own validation UI: ```javascript theme={null} const compliancy = await client.getPasswordCompliancy(); // { min_length: 12, max_length: 128, uppercase: 1, lowercase: 1, numbers: 1, symbols: 1 } ``` Replace `src/App.jsx` with: ```jsx src/App.jsx theme={null} import "@picocss/pico"; import { useState, useEffect } from "react"; import { PrldSessionClient } from "@prelude.so/js-sdk"; const client = new PrldSessionClient({ domain: `${import.meta.env.VITE_APP_ID}.session.prelude.dev` }); export default function App() { const [password, setPassword] = useState(""); const [results, setResults] = useState([]); const [valid, setValid] = useState(null); useEffect(() => { if (!password) return setResults([]); client.validatePassword(password).then((r) => { setResults(r.results); setValid(r.valid); }); }, [password]); return ( <>

Password Validator

setPassword(e.target.value)} /> {results.length > 0 && ( {results.map((r) => ( ))}
CriteriaStatusExpectedActual
{r.criteria} {r.valid ? "\u2713" : "\u2717"} {r.expected} {r.actual}
)} {valid !== null &&

{valid ? "\u2713 Password is valid" : "\u2717 Password does not meet requirements"}

}
); } ```
# SAML Login Source: https://docs.prelude.so/auth/documentation/frontend-sdks/web/saml Implement SAML SSO login with the Prelude JavaScript SDK. 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](/session/documentation/integration-guide/saml/introduction) on your backend before proceeding. ## SAML login flow An SP-initiated SAML login involves three steps: 1. **Initiate** — Your app asks the SDK to start the flow; it redirects the user to the Identity Provider. 2. **Callback** — After authenticating, the IdP posts back and Prelude redirects to your app with a `challenge_token`. 3. **Finalize** — Your app sends the `challenge_token` to complete authentication. The `challenge_token` is finalized exactly like [social login](/session/documentation/frontend-sdks/web/social-login) — via `finalizeOAuthLogin`. SP-initiated SAML is protected by [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) (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`: ```javascript theme={null} 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`: ```javascript theme={null} 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: ```javascript theme={null} 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: ```jsx src/App.jsx theme={null} 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 ( <>

SAML Login

{loading ? (

Completing login...

) : user ? (

Logged in

                {JSON.stringify(decode(user.accessToken).claims, null, 2)}
              
) : ( <> {error &&

{error}

} setEmail(e.target.value)} /> )}
); } ```
## What's next? If your application requires certain email domains to use SSO exclusively, see [Enforce SSO login](/session/documentation/frontend-sdks/web/saml-enforce) for handling the `saml_login_required` fallback. # Enforce SSO Login Source: https://docs.prelude.so/auth/documentation/frontend-sdks/web/saml-enforce Transparently fall back to SAML when a domain enforces SSO, with the Prelude JavaScript SDK. When a SAML connection has [enforce login](/session/documentation/integration-guide/saml/enforce) 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`: ```javascript theme={null} 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: ```javascript theme={null} 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](/session/documentation/frontend-sdks/web/saml#handle-the-callback) 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. ```jsx src/App.jsx theme={null} 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 ( <>

Sign in

{loading ? (

Loading...

) : user ? (

Logged in

                {JSON.stringify(decode(user.accessToken).claims, null, 2)}
              
) : ( <> {error &&

{error}

} {!otpSent ? ( <> setEmail(e.target.value)} /> ) : ( <> setCode(e.target.value)} /> )} )}
); } ```
## What's next? Read the [Enforce SSO login](/session/documentation/integration-guide/saml/enforce) integration guide for the backend configuration, or the [SAML Login](/session/documentation/frontend-sdks/web/saml) guide for the standard flow. # Session Management Source: https://docs.prelude.so/auth/documentation/frontend-sdks/web/session-management Manage access tokens, refresh sessions, and handle logout with the Prelude JavaScript SDK. ## Refresh the access token Access tokens are short-lived. Use the `refresh` method to obtain a new one without requiring the user to log in again. The SDK handles caching and prevents concurrent refresh calls across browser tabs automatically. The refresh flow is protected by [DPoP](https://datatracker.ietf.org/doc/html/rfc9449) (Demonstration of Proof-of-Possession). The SDK generates a cryptographic key pair and signs each refresh request with a proof that binds the request to the client. This protects against: * **Token theft** — A stolen refresh cookie is unusable without the private key, which is bound to the browser and never transmitted * **Token replay** — Each DPoP proof includes a unique identifier and timestamp, preventing reuse * **Man-in-the-middle attacks** — The proof binds to the HTTP method and URL, so it cannot be replayed against a different endpoint * **Token export** — The key pair is non-extractable, meaning it cannot be copied from the browser to another device ```javascript theme={null} const { user } = await client.refresh(); // user.accessToken contains the new JWT // user.profile contains the user profile data ``` To force the next `refresh` call to hit the backend (bypassing the local cache), invalidate the cache first: ```javascript theme={null} await client.invalidateCache(); ``` In the login `App.jsx` above, add `decode` to your import (`import { ..., decode } from "@prelude.so/js-sdk"`) and replace the `article` block with: ```jsx theme={null}

Logged in

      {JSON.stringify(decode(user.accessToken).claims, null, 2)}
    
``` Each click fetches a new access token and updates the decoded token content.
## Log out When a user logs out, call `logout` to revoke the session and clear the local cache: ```javascript theme={null} await client.logout(); ``` Add a logout button next to the refresh button in the authenticated view: ```jsx theme={null} ``` The user is redirected back to the login form after logout. ## List sessions Retrieve all active sessions for the authenticated user: ```javascript theme={null} const { sessions, total } = await client.listSessions({ limit: 10, offset: 0, }); ``` Each session contains `id`, `device_type`, `device_model`, `os_version`, `country_code`, `created_at`, `last_seen_at`, and `expires_at`. Replace `src/App.jsx` with: ```jsx src/App.jsx theme={null} import "@picocss/pico"; import { useState, useEffect } from "react"; import { PrldSessionClient } from "@prelude.so/js-sdk"; const client = new PrldSessionClient({ domain: `${import.meta.env.VITE_APP_ID}.session.prelude.dev` }); export default function App() { const [loggedIn, setLoggedIn] = useState(false); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { client.refresh() .then(() => { setLoggedIn(true); return client.listSessions(); }) .then(({ sessions }) => setSessions(sessions)) .catch(() => {}) .finally(() => setLoading(false)); }, []); if (loading) return

Loading...

; if (!loggedIn) return

Please log in first using one of the login examples.

; return ( <>

Sessions

{sessions.map((s) => ( ))}
Session ID Device Country Last Seen
{s.id} {s.device_model || s.device_type || "-"} {s.country_code || "-"} {new Date(s.last_seen_at).toLocaleString()}
); } ```
## Revoke sessions Use `revokeSessions` to revoke sessions. The `target` parameter controls which sessions are revoked: | Target | Description | | ----------- | ----------------------------------------------- | | `"all"` | Revoke all sessions, including the current one. | | `"others"` | Revoke all sessions except the current one. | | `"mine"` | Revoke the current session only. | | `"session"` | Revoke a specific session by ID. | ```javascript theme={null} // Revoke all sessions await client.revokeSessions("all"); // Revoke all other sessions await client.revokeSessions("others"); // Revoke the current session await client.revokeSessions("mine"); // Revoke a specific session by ID await client.revokeSessions("session", sessionId); ``` Building on the list sessions example above, add revocation controls. Replace `src/App.jsx` with: ```jsx src/App.jsx theme={null} import "@picocss/pico"; import { useState, useEffect } from "react"; import { PrldSessionClient } from "@prelude.so/js-sdk"; const client = new PrldSessionClient({ domain: `${import.meta.env.VITE_APP_ID}.session.prelude.dev` }); export default function App() { const [loggedIn, setLoggedIn] = useState(false); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const fetchSessions = async () => { const { sessions } = await client.listSessions(); setSessions(sessions); }; useEffect(() => { client.refresh() .then(() => { setLoggedIn(true); return fetchSessions(); }) .catch(() => {}) .finally(() => setLoading(false)); }, []); const handleRevoke = async (target, sessionId) => { setError(null); try { await client.revokeSessions(target, sessionId); if (target === "all" || target === "mine") { setLoggedIn(false); setSessions([]); } else { await fetchSessions(); } } catch (err) { setError(err.message || "Failed to revoke session."); } }; if (loading) return

Loading...

; if (!loggedIn) return

Please log in first using one of the login examples.

; return ( <>

Session Revocation

{error &&

{error}

}
{sessions.map((s) => ( ))}
Session ID Device Country Last Seen Actions
{s.id} {s.device_model || s.device_type || "-"} {s.country_code || "-"} {new Date(s.last_seen_at).toLocaleString()}
); } ```
## Verify access tokens on your backend Your backend should verify the JWT access token on each authenticated request. Retrieve the public keys from your application's [JWKS endpoint](/session/documentation/jwks): ``` https://{app_id}.session.prelude.dev/.well-known/jwks.json ``` Use any standard JWT library to verify the token signature against these keys. # Social Login Source: https://docs.prelude.so/auth/documentation/frontend-sdks/web/social-login Implement social login with the Prelude JavaScript SDK. This guide covers how to implement social login (OAuth) in your web application. Make sure you have [configured a social login provider](/session/documentation/integration-guide/social-login) 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`](/session/documentation/integration-guide/social-login#verify-email-via-otp) 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](#verify-email-via-otp) below. The entire flow is protected by [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) (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: ```javascript theme={null} 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: ```javascript theme={null} 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`](/session/documentation/integration-guide/social-login#verify-email-via-otp) enabled and the IdP returns an unverified email, Auth does not finalize the login on the callback. Instead, the redirect carries `status=otp_required` alongside the `challenge_token`, and `finalizeOAuthLogin` returns: ```typescript theme={null} { 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`: ```javascript theme={null} 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 replays the verification token persisted by the SDK // 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: ```jsx src/App.jsx theme={null} 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", "linkedin"]; 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 ( <>

Social Login

{loading ? (

Completing login...

) : user ? (

Logged in

                {JSON.stringify(decode(user.accessToken).claims, null, 2)}
              
) : ( <> {error &&

{error}

}
{providers.map((provider) => ( ))}
)}
); } ```
# Step-Up Authentication Source: https://docs.prelude.so/auth/documentation/frontend-sdks/web/step-up Implement step-up authentication with the Prelude JavaScript SDK. ## Request a scope Initiate a step-up flow for a given scope. The SDK handles challenge token caching, DPoP proofs, and automatic session refresh on completion. ```javascript theme={null} import { PrldErrors } from "@prelude.so/js-sdk"; try { const { status } = await client.requestStepUp({ scope: "transfer:write", metadata: { amount: "500", currency: "USD" }, onChallenge: (info) => { // Called when a challenge is created or the scope is granted console.log(info.currentStep); // current step key, or "completed" console.log(info.steps); // array of { order, key, done, expirationDuration } console.log(info.challengeId); // use this to drive OTP and continue flows }, }); if (status === "block") { // Scope denied by your backend } // "continue" → scope granted immediately (session refreshed automatically) // "review" → challenge created, complete the steps below } catch (error) { if (error instanceof PrldErrors.ScopeNotAllowed) { // Scope is not in the allowed_scopes configuration } } ``` | Field | Required | Description | | ------------- | -------- | --------------------------------------------------------------------------------------------------------- | | `scope` | Yes | The scope to request. Must match an `allowed_scopes` entry. | | `metadata` | No | Key-value pairs forwarded to your [hook](/session/documentation/step-up-hook) (e.g. transaction details). | | `onChallenge` | No | Callback receiving challenge info after each step transition. | The `onChallenge` callback receives a `StepUpChallengeInfo` object: | Field | Type | Description | | ---------------- | -------- | ---------------------------------------------------------------------- | | `currentStep` | `string` | The key of the current step, or `"completed"` when all steps are done. | | `scopeRequested` | `string` | The scope being requested. | | `challengeId` | `string` | The challenge ID — pass this to `startOTP` and `checkOTP`. | | `steps` | `array` | All steps with `order`, `key`, `done`, and `expirationDuration`. | | `userId` | `string` | The Prelude user ID. | | `sessionId` | `string` | The current session ID. | ## Complete a managed OTP step When the current step is `verify_sms` or `verify_email`, use the OTP methods with the `challengeId` from `onChallenge`: ```javascript theme={null} // Send the OTP await client.startOTP({ challengeId: challengeId }); // Verify the code — advances to the next step automatically await client.checkOTP({ code: "123456", challengeId: challengeId, onChallenge: (info) => { console.log(info.currentStep); // next step key, or "completed" }, }); ``` If the user didn't receive the code: ```javascript theme={null} await client.retryOTP(); ``` ## Complete a custom step For [custom steps](/session/documentation/step-up-custom-steps) (e.g. `kyc_review`), your backend issues a verification token after the user completes the step on your side. Pass it to the SDK: ```javascript theme={null} // verificationToken is the RS256 JWT issued by your backend await client.continueStepUp(verificationToken, (info) => { console.log(info.currentStep); // next step key, or "completed" }); ``` The SDK extracts the `challenge_id` from the verification token, retrieves the cached challenge token, and sends both to Prelude. ## Automatic completion When the last step is completed, the SDK automatically: 1. Refreshes the session with the challenge token 2. Clears the step-up cache for that challenge 3. Calls your `onChallenge` callback with `currentStep: "completed"` The new access token from `client.refresh()` will include the granted scope. No manual refresh call is needed. This example builds on the project from [Introduction](/session/documentation/frontend-sdks/web/introduction). Make sure you have a working OTP login first ([OTP Login](/session/documentation/frontend-sdks/web/otp)). **1. Create a mock hook** Go to [mockerapi.com](https://mockerapi.com/mock-api-generator) and create a new mock API that returns the following JSON on `POST`: ```json theme={null} { "status": "review", "granted_for": 300, "grant_mode": "single-use", "steps": [ { "order": 1, "key": "verify_sms", "expiration_duration": 600 } ] } ``` Copy the generated mock URL (e.g. `https://free.mockerapi.com/mock/xxxxxxxx`). **2. Configure step-up** Create a step-up configuration pointing to your mock hook. The `jwks_url` can be any valid URL since we only use managed steps here: ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "jwks_url": "https://example.com/.well-known/jwks.json", "step_keys": [], "allowed_scopes": [ { "scope": "transfer:write", "mode": "delegated", "delegated": { "delegation_hook": "YOUR_MOCK_URL" } } ] }' ``` **3. Add the scope** Register the scope on your application: ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/scopes \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "scope": "transfer:write" }' ``` **4. Replace `src/App.jsx`** ```jsx src/App.jsx theme={null} import "@picocss/pico"; import { useState } 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 [view, setView] = useState("login"); // login | code | logged | stepup-otp | done const [phone, setPhone] = useState(""); const [code, setCode] = useState(""); const [stepUpCode, setStepUpCode] = useState(""); const [challengeId, setChallengeId] = useState(null); const [user, setUser] = useState(null); const [error, setError] = useState(null); // ── Login flow ────────────────────────────────────────────── const handleSendOTP = async (e) => { e.preventDefault(); setError(null); try { await client.startOTP({ identifier: { type: "phone_number", value: phone } }); setView("code"); } catch (err) { setError(err.message); } }; const handleCheckLogin = async (e) => { e.preventDefault(); setError(null); try { await client.checkOTP({ code }); const { user } = await client.refresh(); setUser(user); setView("logged"); } catch (err) { if (err instanceof PrldErrors.BadCheckCode) setError("Wrong code."); else setError(err.message); } }; // ── Step-up flow ──────────────────────────────────────────── const handleChallenge = (info) => { setChallengeId(info.challengeId); if (info.currentStep === "completed") { client.refresh().then(({ user }) => { setUser(user); setView("done"); }); } else if (info.currentStep === "verify_sms" || info.currentStep === "verify_email") { setView("stepup-otp"); client.startOTP({ challengeId: info.challengeId }); } }; const handleStepUp = async () => { setError(null); try { const { status } = await client.requestStepUp({ scope: "transfer:write", onChallenge: handleChallenge, }); if (status === "block") setError("Scope denied by hook."); } catch (err) { setError(err.message); } }; const handleCheckStepUp = async (e) => { e.preventDefault(); setError(null); try { await client.checkOTP({ code: stepUpCode, challengeId: challengeId, onChallenge: handleChallenge }); } catch (err) { if (err instanceof PrldErrors.BadCheckCode) setError("Wrong code."); else setError(err.message); } }; // ── Render ────────────────────────────────────────────────── return ( <>

Step-Up Demo

{error &&

{error}

} {view === "login" && (
setPhone(e.target.value)} required />
)} {view === "code" && (
setCode(e.target.value)} required />
)} {view === "logged" && (

Logged in. Access token scopes: {decode(user.accessToken).claims.scope || "none"}

)} {view === "stepup-otp" && (

Step-up: verify your phone number

setStepUpCode(e.target.value)} required />
)} {view === "done" && user && (

Scope granted

                {JSON.stringify(decode(user.accessToken).claims, null, 2)}
              
)}
); } ``` Run `npm run dev`, log in with your phone number, then click **Request transfer:write scope**. You'll receive a second OTP to complete the step-up challenge. After verification, the access token will include the `transfer:write` scope.
# Groups Source: https://docs.prelude.so/auth/documentation/groups Grant scopes to users in bulk by organizing them into groups. A **group** is a named collection of scopes defined at the application level. Assign a user to a group and, on their next login, the group's scopes are added to their access token. Groups let you manage authorization by role — `admins`, `editors`, `billing` — instead of granting scopes to each user one by one. ## Prerequisites * A Prelude account with access to the Auth API * An **Application ID** (`appID`) — see [Applications](/session/documentation/applications) * Your **Management API key** for backend calls * One or more **allowed scopes** declared on the application — see [Scopes](/session/api-reference/management/config/scopes/list-scopes) ## How it works A group owns a set of scopes, and every scope a group owns must be one of the application's **allowed scopes**. Users are assigned to groups; a user can belong to several groups. Two things happen at different times: * **At session creation** (login), the user's current group membership is snapshotted onto the session. * **At every access-token mint** (login *and* refresh), each group on the session is looked up in the current application configuration and the scopes of the matching groups are merged into the token's `scope` claim — alongside the user's own scopes. ```mermaid theme={null} flowchart LR A[App allowed scopes] --> B[Group: admins
owns read, write] B --> C[User assigned to admins] C -->|snapshot at login| D[Session groups: admins] D -->|resolved at every mint| E[Access token
scope: read write] ``` This split has two consequences worth remembering: **Membership is pinned at session creation.** Adding or removing a user from a group does not change the scopes of their already-active sessions — it takes effect the next time they log in. Changing the **scopes a group owns**, on the other hand, is reflected on the next access-token refresh of every existing session, because group scopes are resolved from the live configuration at mint time. ## Configure groups A group can only own scopes the application already allows. Add them first: ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/{appID}/config/scopes \ -H "Authorization: Bearer $MANAGEMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "scope": "read" }' ``` Create a group and give it a subset of the allowed scopes. A group name is unique per application and may contain letters, digits, `-` and `_`. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/{appID}/config/groups \ -H "Authorization: Bearer $MANAGEMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "admins", "scopes": ["read", "write"] }' ``` If any scope is not in the application's allowed scopes, the request fails with `scope_not_allowed` and lists every offending scope. Assign a user to the group. The call is idempotent — assigning a user who is already a member succeeds without changing anything. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/{appID}/users/{userID}/groups \ -H "Authorization: Bearer $MANAGEMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "group": "admins" }' ``` The next time the user logs in, their access token's `scope` claim will include `read` and `write`. Group **scopes** always land in the `scope` claim. If you also want the group **names** in the token, map the `groups` input in your [claims mapping](/session/api-reference/management/config/claims/get-claims-mapping): ```json theme={null} { "roles": { "$input": "groups", "$type": "string-array" } } ``` This adds e.g. `"roles": ["admins"]` to the token. Group names are resolved from the user's profile, so this claim stays in sync with membership changes on the next claims recomputation. ## Update or remove a group Replace a group's scopes with `PUT /config/groups/{groupName}`; the new set takes effect on the next access-token mint of every session that belongs to the group. ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/{appID}/config/groups/admins \ -H "Authorization: Bearer $MANAGEMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "scopes": ["read", "write", "admin"] }' ``` Remove a single user from a group with `DELETE /users/{userID}/groups/{groupName}` (idempotent), or delete the group definition entirely with `DELETE /config/groups/{groupName}`. Once a group is deleted, its scopes are no longer granted at mint time. Group **membership** and group **scopes** are enforced only through the access token minted by Prelude. Always authorize sensitive operations on your backend against the `scope` claim of a freshly minted (or refreshed) access token, not against a cached one. ## API reference * [List groups](/session/api-reference/management/config/groups/list-groups) * [Create group](/session/api-reference/management/config/groups/create-group) * [Get group](/session/api-reference/management/config/groups/get-group) * [Set group scopes](/session/api-reference/management/config/groups/set-group-scopes) * [Delete group](/session/api-reference/management/config/groups/delete-group) * [Add user to group](/session/api-reference/management/users/add-user-to-group) * [Remove user from group](/session/api-reference/management/users/remove-user-from-group) # Introduction Source: https://docs.prelude.so/auth/documentation/integration-guide/enterprise-oidc/introduction Configure enterprise single sign-on (SSO) over OpenID Connect for your Auth application. Enterprise SSO over OpenID Connect (OIDC) lets your users authenticate through their organization's Identity Provider (IdP), such as Okta, Google Workspace, or Microsoft Entra ID. It reuses the same OAuth authorization-code flow as [social login](/session/documentation/integration-guide/social-login/introduction) — the difference is tenancy: instead of one global "Sign in with Google" button, you register one or more **enterprise connections**, each scoped to a customer's IdP and bound to their email domains. ## Prerequisites Before you start, make sure you have: * A Prelude account with access to Prelude Auth * An **Application ID** (`appID`) — see [Applications](/session/documentation/applications) * Your **Management API key** for backend calls * OAuth credentials from the customer's IdP application (client ID, client secret, and for Okta the issuer URL) * Admin access to the Identity Provider you want to connect ## Supported providers Enterprise connections are available for the OIDC-capable providers: | Provider | Identifier | | ------------------ | ----------- | | Okta | `okta` | | Google Workspace | `google` | | Microsoft Entra ID | `microsoft` | ## Personal vs enterprise connections Each OAuth provider config carries a `type`: * **`personal`** (the default) — the classic social-login button. At most one per provider, addressed as `…/oauth/{provider}`. * **`enterprise`** — a per-customer OIDC connection. An app can hold any number of them per provider, each addressed as `…/oauth/{provider}/{connection_id}`. Creating an enterprise connection never conflicts with a personal one for the same provider. Two enterprise connections conflict only if they claim the same email domain — a domain must resolve to exactly one connection. The `type` of a connection is immutable. To switch between personal and enterprise, delete the config and create a new one. ## Create a connection Enterprise connections are managed through the same endpoint as social login, with `type` set to `enterprise` and an `enterprise` block: ```bash theme={null} curl -X POST https://api.prelude.so/v2/session/apps/{appID}/config/login/oauth/okta \ -H "Authorization: Bearer $PRELUDE_MANAGEMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "enterprise", "client_id": "0oa1b2c3d4EXAMPLE", "client_secret": "…", "enabled": true, "scopes": ["openid", "email", "profile"], "enterprise": { "issuer_url": "https://acme.okta.com/oauth2/default", "email_domain_allowlist": ["acme.com"], "jit_provisioning": true, "allow_email_account_merge": true, "sync_profile_on_login": true, "default_redirect_uri": "https://acme.yourapp.com/callback" } }' ``` The response includes the generated `connection_id` (an `ocon_…` identifier). Use it to update, delete, or launch the connection. | Method | Path | | ---------------------------------------- | -------------------------------------------------------- | | List all configs (personal + enterprise) | `GET …/config/login/oauth` | | Create | `POST …/config/login/oauth/{provider}` | | Update | `PUT …/config/login/oauth/{provider}/{connection_id}` | | Delete | `DELETE …/config/login/oauth/{provider}/{connection_id}` | ## Connection behavior The `enterprise` block controls provisioning, login, and profile syncing: | Field | Type | Description | | --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `issuer_url` | string | The IdP's OIDC issuer (https). Required. For Okta this is the authorization server URL, e.g. `https://acme.okta.com/oauth2/default`. | | `email_domain_allowlist` | string\[] | Email domains this connection covers. **Required and non-empty.** It is the domain→connection binding used for email-resolved login, and every login's IdP-asserted email must fall within it. | | `jit_provisioning` | boolean | When `true`, just-in-time provisions a new user on first login. When `false`, a user with no existing match is rejected. | | `allow_email_account_merge` | boolean | When `true`, links the OIDC identifier to an existing user that owns the same email. | | `enforce_login` | boolean | Reserved for future parity with SAML enforcement. | | `sync_profile_on_login` | boolean | When `true`, refreshes `name`, `given_name`, `family_name`, and `picture` from the IdP on every login instead of only at first provisioning. | | `default_redirect_uri` | string | Redirect URI used when a login flow omits `redirect_uri`. Must be allowlisted for your app. | | `claim_mapping` | object | Optional overrides for the id\_token claim names used to read `email`, `given_name`, `family_name`, and arbitrary `custom` claims. Sensible defaults apply when omitted. | ## How the flow works An enterprise login is the same authorization-code flow as social login: 1. **Initiate** — Your app starts the flow by **email** (resolved to a connection via its domain allowlist) or by **connection id**. Prelude derives the provider from the connection and redirects the user to the IdP. 2. **Callback** — After authenticating, the IdP redirects back and Prelude redirects to your app with a `challenge_token`. 3. **Finalize** — Your app finalizes the `challenge_token`, exactly as for [social login](/session/documentation/frontend-sdks/web/social-login). The key difference from personal social login is the **email domain check**: because the connection is pre-bound to one or more domains, a matching domain is proof enough of the user's organization, so there is no `verify_email` OTP step. A login whose IdP email falls outside the allowlist is refused (`oauth_email_domain_not_allowed`). Enterprise identities are scoped per connection. The stored identifier is `oauth:{provider}:{connection_id}`, so the same provider subject seen through two different connections never collapses onto one account. ## Getting the email from Microsoft Entra ID Microsoft only includes an `email` claim in the id\_token when the signed-in user has an email set on their profile, or when the app registration requests the `email` **optional claim**. If Entra logins reach the callback without an email — which an enterprise connection rejects, since it cannot match a domain — add the optional claim: 1. In the Azure portal, open **App registrations → your app → Token configuration**. 2. Choose **Add optional claim**, select **ID**, and add **email**. 3. Request the `email` scope in the connection's `scopes`. For accounts whose email is not part of the tenant directory, also ensure the user has a verified email on their Microsoft profile. ## What's next? Start an enterprise SSO login from the JavaScript SDK. Configure classic social-login providers. # Integration Guide Source: https://docs.prelude.so/auth/documentation/integration-guide/introduction Step-by-step guides to integrate Prelude Auth into your application. These guides walk you through integrating the Prelude Auth into your application, from authentication to session management. ## Available guides Configure SMS one-time password authentication from your backend. Configure email and password authentication from your backend using the Management API. Configure OAuth providers (Google, Apple, Microsoft, GitHub, Okta). Add scoped, multi-step challenges for sensitive operations. Let MCP clients sign your users in using Prelude Auth as the OAuth authorization server. Add single sign-on across your own apps using Prelude Auth as the OAuth authorization server. Authenticate server-to-server requests using OAuth client credentials. Once configured, follow the [Web Integration](/auth/documentation/frontend-sdks/web) guide to integrate the SDK into your frontend. ## General concepts Regardless of which authentication method you use, Prelude Auth follows the same patterns: * **Management API** (backend) — Configure authentication methods, create users, and manage identifiers using your API key * **Frontend API** — Authenticate users from the browser through your [custom domain](/auth/documentation/domain-names). The API returns JWT access tokens and manages sessions via headers and IndexDB * **Token verification** (backend) — Verify JWT access tokens on each authenticated request using the [JWKS endpoint](/auth/documentation/jwks) ## What's next? * Set up [Webhooks](/auth/documentation/webhooks/introduction) to receive real-time notifications when users are created or sessions change * Explore the [Management API](/auth/api-reference/management/users/list-users) for advanced user management # MCP Login Source: https://docs.prelude.so/auth/documentation/integration-guide/mcp Let MCP clients sign your users in by configuring Prelude Auth as the OAuth 2.0 authorization server for your MCP server. The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) authorization spec has MCP servers delegate authentication to an OAuth 2.0 authorization server. Prelude Auth **is** that authorization server: enable the OAuth server surface on your app and MCP clients — Claude, IDE agents, or any spec-compliant client — sign users into your MCP server against your existing Prelude Auth users, with no extra identity provider to run. This guide configures the authorization server. Your MCP server stays the OAuth **resource server**: it advertises Prelude Auth as its authorization server and verifies the access tokens Prelude issues. ## How it works Prelude Auth implements the authorization-code flow with PKCE ([RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) + [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)) and publishes [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) authorization-server metadata. Clients register themselves — no manual onboarding — through either mechanism the MCP authorization spec allows: * **Dynamic Client Registration (DCR, [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591))** — the client `POST`s its metadata to a registration endpoint and receives a generated `client_id`. * **Client ID Metadata Document (CIMD, MCP 2025-11-25 / SEP-991)** — the client's `client_id` **is** an `https` URL that serves its metadata document. Prelude fetches and caches it on first use; there is no registration call. A login looks like this: The client reads `GET /.well-known/oauth-authorization-server` to learn the authorize, token, registration, and JWKS endpoints. The client registers via DCR or presents a CIMD `client_id` URL — whichever you enabled. The client redirects the browser to `/v1/session/oauth/authorize` with PKCE. Prelude persists the request and `302`s to your login UI. The user signs in with any method your app supports (OTP, password, passkey, social, SAML). Your login UI then shows the consent screen with the client name and requested scopes. On approval, Prelude redirects back to the client's `redirect_uri` with a one-time `code`. The client exchanges it at `/v1/session/oauth/token` (with its PKCE `code_verifier`) for an access token and refresh token. The issued session is a **child** of the user's browser session. Its scopes are the intersection of the requested scopes and the parent session's scopes (or the parent's full set when the client requests the `prld:oauth:inherit` scope). A session minted through the OAuth flow cannot itself start another OAuth flow — the chain is capped at one level. ## Prerequisites Before you start, make sure you have: * A Prelude account with access to Prelude Auth * An **Application ID** (`appID`) — see [Applications](/auth/documentation/applications) * Your **Management API key** for backend calls * A verified [custom domain](/auth/documentation/domain-names), or your default `${APP_ID}.session.prelude.dev` host — the authorization-server endpoints are anchored to this **auth domain** * A login UI built with the [Web SDK](/auth/documentation/frontend-sdks/web/introduction) that can host the sign-in and consent screens (see [Host the login and consent screens](#host-the-login-and-consent-screens)) ## Enable the OAuth authorization server Configure the OAuth server on your app with a single `PUT`. The presence of the `registration.dcr` and `registration.cimd` sub-objects is what enables each mechanism — omit one to leave it disabled. ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/oauth \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "default_provider_url": "https://auth.example.com", "registration": { "redirect_uri_allowlist": [ "https://claude.ai/api/mcp/auth_callback", "http://localhost/*", "http://127.0.0.1/*" ], "dcr": { "default_scopes": ["mcp:read"], "auto_registered_client_ttl_seconds": 2592000 }, "cimd": { "client_url_allowlist": ["https://claude.ai/*"], "default_scopes": ["mcp:read"], "cache_ttl_seconds": 86400 } } }' ``` | Field | Required | Description | | ----------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `default_provider_url` | Yes | Origin of your login UI. Prelude `302`s the browser to `/sign-in` from `/authorize` and to `/oauth/consent` after login. Must be an absolute `http(s)` URL. | | `registration.redirect_uri_allowlist` | Yes | URI patterns every client `redirect_uri` is validated against. Each entry is a literal URI or a prefix ending in a single `*` (e.g. `https://*.anthropic.com/*`). Loopback redirects (`http://localhost`, `http://127.0.0.1`) are matched with the port removed, per [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252). | | `registration.dcr` | No | Enables Dynamic Client Registration. Omit to disable it (the `registration_endpoint` is then dropped from discovery and `/oauth/register` returns `403`). | | `registration.dcr.default_scopes` | No | Scopes seeded onto every DCR-registered client, on top of what it requests. | | `registration.dcr.auto_registered_client_ttl_seconds` | No | Expiry for DCR-issued clients. Omit for no expiry; must be positive when set. | | `registration.cimd` | No | Enables Client ID Metadata Documents. Omit to disable it. | | `registration.cimd.client_url_allowlist` | No | `https`-only patterns restricting which `client_id` URLs Prelude will fetch. Same literal-or-`*`-suffix shape as the redirect allowlist. `http` is rejected. | | `registration.cimd.default_scopes` | No | Scopes seeded onto every CIMD client. CIMD clients skip the registration step, so cap them tighter here. | | `registration.cimd.cache_ttl_seconds` | No | How long a fetched metadata document is reused across flows. Omit for the resolver default; must be positive when set. | You can enable DCR, CIMD, or both. Modern MCP clients prefer CIMD (no registration round trip); DCR remains the widest-supported fallback. Enabling both lets each client use whichever it implements. Read the current config back with `GET /v2/session/apps/${APP_ID}/oauth`, and turn the surface off entirely with `DELETE /v2/session/apps/${APP_ID}/oauth`. See the [OAuth Server Config API](/auth/api-reference/management/config/oauth-server/update-oauth-server-config) reference for the full schema. ## Register a client manually (optional) For a known integration you can pre-register a client instead of relying on DCR or CIMD. This is also the only way to give a single client its own login UI, distinct from the app's `default_provider_url`. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/oauth/clients \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_name": "Internal MCP client", "redirect_uris": ["https://mcp.example.com/oauth/callback"], "scopes": ["mcp:read", "mcp:write"], "provider_url": "https://auth.example.com" }' ``` | Field | Required | Description | | --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | | `client_name` | Yes | Human-readable name shown on the consent screen. | | `redirect_uris` | Yes | Allowed redirect URIs for this client. | | `scopes` | No | Scopes granted to this client. | | `provider_url` | Yes | Login UI origin for this client. Manually-registered clients do not inherit `default_provider_url` and must set their own. | The response returns the generated `client_id` (an `oac_…` identifier). List clients with `GET …/oauth/clients` and remove one with `DELETE …/oauth/clients/{clientID}`. ## Authorization server endpoints Once enabled, Prelude serves the OAuth surface on your **auth domain** — your verified custom domain, or `https://${APP_ID}.session.prelude.dev` by default. Each is documented under the [OAuth Server](/auth/api-reference/frontend/oauth-server-authorize) API reference: | Endpoint | Method | Purpose | | ----------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `/.well-known/oauth-authorization-server` | GET | RFC 8414 metadata. Advertises `registration_endpoint` only when DCR is enabled and `client_id_metadata_document_supported` when CIMD is enabled. | | `/v1/session/oauth/authorize` | GET | Starts the flow. Requires `response_type=code`, PKCE `code_challenge` with `code_challenge_method=S256`, `client_id`, `redirect_uri`, `scope`, and `state`. | | `/v1/session/oauth/register` | POST | RFC 7591 Dynamic Client Registration. Present only when DCR is enabled. | | `/v1/session/oauth/token` | POST | Exchanges an authorization `code` (with the PKCE `code_verifier`) for tokens, and handles `grant_type=refresh_token`. Public clients — `token_endpoint_auth_method` is `none`. | | `/.well-known/jwks.json` | GET | Signing keys your MCP server uses to verify the issued access tokens. See [JWKS](/auth/documentation/jwks). | PKCE with `S256` is mandatory: `/authorize` rejects a request with no `code_challenge`, and `plain` is not accepted. Authorization codes are single-use and expire after 60 seconds. ## Host the login and consent screens Prelude handles the OAuth protocol but hands the browser to **your** login UI (`default_provider_url`) to authenticate the user and collect consent. Build two routes with the [Web SDK](/auth/documentation/frontend-sdks/web/introduction): Prelude redirects here with an `?oauth_req=` query parameter. Read it, sign the user in with any method your app supports, then resume the flow by calling the SDK's OAuth `continue` action with that `oauth_req`. Prelude returns the consent URL to navigate to next. Prelude redirects here with the same `?oauth_req=`. Fetch the pending request to display the requesting **client name**, **redirect URI**, and **requested scopes**, then submit the user's Approve or Deny decision. Prelude returns the client `redirect_uri` (with `code` and `state` on approval, or `error=access_denied` on denial) for the browser to follow back to the client. The `continue`, `pending`, and `decision` calls run against the user's live session and are bound to it with [DPoP](https://datatracker.ietf.org/doc/html/rfc9449). The Web SDK manages the session and the DPoP proof for you — you do not construct these requests by hand. ## Point your MCP server at Prelude On the MCP server side, advertise Prelude Auth as your authorization server so clients discover it, then verify the tokens Prelude issues: * Serve OAuth protected-resource metadata that points at your Prelude auth domain as the `authorization_servers` entry. * Validate incoming access tokens against the [JWKS endpoint](/auth/documentation/jwks) and check the scopes your MCP tools require. Clients follow the discovery chain from your MCP server to Prelude's `/.well-known/oauth-authorization-server` and run the flow above with no further configuration. ## What's next? Build the sign-in and consent screens your login UI hosts. Verify the access tokens Prelude issues on your MCP server. Anchor the authorization-server endpoints to your own domain. Control which scopes a session — and its OAuth children — can carry. # OTP Login Source: https://docs.prelude.so/auth/documentation/integration-guide/otp-login Configure OTP-based authentication with Prelude Auth using phone or email. This guide walks you through configuring OTP (one-time password) login for your application using Prelude Auth. OTP login sends a verification code to the user's phone number via SMS or to their email address. ## Prerequisites Before you start, make sure you have: * A Prelude account with access to Prelude Auth * An **Application ID** (`appID`) — see [Applications](/session/documentation/applications) * Your **Management API key** for backend calls * A configured [Verify](/verify/v2/documentation/introduction) template for sending OTP codes ## Set up OTP login ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/otp \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "config_type": "otp", "channel_type": "sms", "is_default": true }' ``` | Field | Required | Description | | ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `config_type` | Yes | Must be `"otp"`. | | `channel_type` | Yes | The channel used for OTP delivery (`"sms"` or `"email"`). | | `is_default` | Yes | Whether this is the default OTP login configuration. | | `template_id` | No | The ID of your [Verify template](/verify/v2/documentation/content). This controls the OTP message content and delivery settings. | | `sender_id` | No | The sender ID used for SMS delivery. | | `code_size` | No | OTP code length (number of digits), between `4` and `8`. Applied to verifications created through this login configuration. When omitted, your account's default code size applies. | | `granted_scopes` | No | List of session scopes attached to the session when a login completes through this OTP configuration (for example `["prld:pwd:write"]` to grant a password change). Defaults to an empty list. | This configuration supports both SMS and email channels simultaneously, allowing users to receive OTP codes via either method. ## What's next? Now that OTP login is configured on your backend, integrate the frontend using the [OTP Login](/session/documentation/frontend-sdks/web/otp) guide. # Passkey Authentication Source: https://docs.prelude.so/auth/documentation/integration-guide/passkey Configure WebAuthn passkey authentication for MFA step-up or passwordless sign-in. This guide walks you through configuring passkey authentication on your application using Prelude Auth. Passkeys can serve as an MFA step-up factor (the default) and, optionally, as a primary-factor passwordless sign-in method. For the full conceptual reference — ceremony shape, security model, error catalogue — see the [Passkey](/auth/documentation/passkey) page. ## Prerequisites Before you start, make sure you have: * A Prelude account with access to Prelude Auth * An **Application ID** (`appID`) — see [Applications](/auth/documentation/applications) * Your **Management API key** for backend calls * A frontend served over HTTPS, or `http://localhost:` for local development — the WebAuthn API refuses any other origin ## Set up passkey authentication Configure the WebAuthn Relying Party identity for your app. The RP identity is shared across every passkey ceremony — changing it after credentials are registered invalidates them at the authenticator layer, so set it once per environment. ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/passkey \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "rp_id": "example.com", "rp_name": "Example", "allowed_origins": ["https://example.com", "https://app.example.com"], "user_verification": "required", "attestation_preference": "none", "login_enabled": false }' ``` | Field | Required | Description | | ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `rp_id` | Yes | The Relying Party identifier — the effective domain (no scheme, no port). Credentials are scoped to this RPID; changing it later breaks existing credentials. | | `rp_name` | Yes | Human-readable display name shown by authenticators during ceremonies. | | `allowed_origins` | Yes | List of permitted origins (scheme + host + optional port). Must be a superset of the RPID. For local development, `http://localhost:` is accepted. | | `user_verification` | No | `required` (default), `preferred`, or `discouraged`. Use `required` for MFA so the ceremony proves something the user knows or is, not just possession. | | `attestation_preference` | No | `none` (default), `indirect`, `direct`, or `enterprise`. `none` is privacy-preserving and sufficient for most deployments. Required `direct` or `enterprise` when you want to enforce the AAGUID allowlist below. | | `login_enabled` | No | Defaults to `false`. Set to `true` to opt the app into primary-factor passwordless passkey login. See [Enable passwordless login](#enable-passwordless-login-optional) below. | | `granted_scopes` | No | Session scopes attached to the session minted by a primary-factor passkey login, mirroring the per-OAuth-provider and per-OTP granted scopes. Only applies to the passwordless login flow, not step-up. Empty grants no extra scopes. | | `aaguid_allowlist` | No | List of authenticator-model AAGUIDs (UUID strings) to accept. Empty list disables the filter. See [Enterprise authenticator policy](#enterprise-authenticator-policy-optional) below. | | `aaguid_blocklist` | No | List of AAGUIDs to reject outright. Same shape as the allowlist. | Add the step key to your step-up configuration and reference it from any scope whose challenge should require a passkey. ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "step_keys": [ { "key": "verify_passkey", "description": "WebAuthn second factor" } ], "allowed_scopes": [ { "scope": "transfer:write", "mode": "direct", "direct": { "identifier_types": ["passkey"], "status": "review", "grant_mode": "single-use", "granted_for": 600, "steps": [ { "order": 1, "key": "verify_passkey", "expiration_duration": 60 } ] } } ] }' ``` A registered passkey shows up on the user as an identifier of type `passkey`, so direct-mode entries select on it via `identifier_types` like any other identifier. To express a passkey-or-OTP fallback, list two direct entries on the same scope — the passkey-gated one first, the OTP fallback second. The runtime serves the first one whose identifier types the user holds: ```json theme={null} "allowed_scopes": [ { "scope": "transfer:write", "mode": "direct", "direct": { "identifier_types": ["passkey"], "status": "review", "grant_mode": "single-use", "granted_for": 600, "steps": [{ "order": 1, "key": "verify_passkey", "expiration_duration": 60 }] } }, { "scope": "transfer:write", "mode": "direct", "direct": { "identifier_types": ["email_address", "phone_number"], "status": "review", "grant_mode": "single-use", "granted_for": 600, "steps": [{ "order": 1, "key": "verify_sms", "expiration_duration": 300 }] } } ] ``` ## Enable passwordless login (optional) Set `login_enabled: true` on the PasskeyConfig to opt the app into primary-factor passkey sign-in. While the flag is on, registration also requests a discoverable credential (`residentKey: required`) so the resulting passkey shows up in the browser's autofill chip. ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/passkey \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "rp_id": "example.com", "rp_name": "Example", "allowed_origins": ["https://example.com"], "user_verification": "required", "attestation_preference": "none", "login_enabled": true }' ``` **Existing credentials may not be discoverable.** Turning `login_enabled` on does not retroactively migrate credentials registered while it was off — those were created with the WebAuthn default and platform passkeys (iCloud Keychain, Google, Microsoft, 1Password, ...) are typically discoverable but older hardware security keys often are not. Affected users keep using step-up MFA without change, but to use the passwordless flow they need to register a new credential. Authenticators that can't store a resident key — most older hardware security keys — will refuse the ceremony with `passkey_registration_failed` after the flag flips on. ## Enterprise authenticator policy (optional) Restrict registration to specific authenticator models via the AAGUID allowlist / blocklist on the PasskeyConfig. Pairs with `attestation_preference: "direct"` or `"enterprise"` — with `"none"` most authenticators return an all-zeros AAGUID and the allowlist matches nothing. ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/passkey \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "rp_id": "example.com", "rp_name": "Example", "allowed_origins": ["https://example.com"], "user_verification": "required", "attestation_preference": "direct", "aaguid_allowlist": [ "ee882879-721c-4913-9775-3dfcce97072a", "08987058-cadc-4b81-b6e1-30de50dcbe96" ], "aaguid_blocklist": [ "00000000-0000-0000-0000-000000000000" ] }' ``` The blocklist wins on collision. Filters only run at **registration** time — credentials stored before the policy was tightened remain valid for assertions, so changing the rule doesn't retroactively lock anyone out. The FIDO Alliance publishes a [Metadata Service](https://fidoalliance.org/metadata/) that maps AAGUIDs to authenticator vendor/model names — useful when curating the allowlist. ## Subscribe to passkey lifecycle events (optional) Three webhook events surface passkey activity for audit and user notifications: | Event | When | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `user.passkey.registered` | A user completes the registration ceremony. The "we added a new passkey to your account — wasn't you?" notification flow keys on this event. | | `user.passkey.deleted` | A user removes a credential (via the SDK or via the compromise-response `/me/revoke?target=all` flow). | | `user.passkey.assertion_failed` | An assertion fails to verify — bad signature, sign-count regression, expired login token, etc. Useful for brute-force / cloned-authenticator monitoring. | Subscribe via the existing webhook configuration: ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/webhooks \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/webhooks/prelude", "events": ["user.passkey.registered", "user.passkey.deleted", "user.passkey.assertion_failed"] }' ``` See the [Passkey](/auth/documentation/passkey#webhook-events) reference for the typed payload shape. ## Surface passkey state in access tokens (optional) The custom-claims pipeline exposes a `has_passkey` template input. Map it on your app's claims configuration to let your frontend decide whether to prompt the user to enrol: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/claims \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "mapping": { "has_passkey": { "$input": "has_passkey", "$type": "bool" } } }' ``` The flag is recomputed on every access-token issuance and flips on the active session's next refresh as soon as a credential is registered or removed — no extra round trip needed. ## What's next? Now that your backend is configured, integrate the frontend using the [Web Passkey SDK](/auth/documentation/frontend-sdks/web/passkey) guide. For the full reference — ceremony walk-through, security model, error catalogue, AAGUID policy details, and webhook payloads — see the [Passkey](/auth/documentation/passkey) page. # Password Authentication Source: https://docs.prelude.so/auth/documentation/integration-guide/password-authentication Configure email and password authentication with Prelude Auth. This guide walks you through configuring email and password authentication for your application using Prelude Auth. ## Prerequisites Before you start, make sure you have: * A Prelude account with access to Prelude Auth * An **Application ID** (`appID`) — see [Applications](/auth/documentation/applications) * Your **Management API key** for backend calls ## Set up password authentication Configure password authentication for your application using the Management API from your backend. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/password \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "enabled": true, "rate_limit_login_ip": { "ttl": 600000000000, "limit": 10 }, "rate_limit_login_identifier": { "ttl": 600000000000, "limit": 10 }, "password_compliancy": { "min_length": 8, "max_length": 128, "uppercase": 1, "lowercase": 1, "numbers": 1, "symbols": 1 } }' ``` | Field | Description | | ----------------------------- | ---------------------------------------------------------------------------------------------- | | `hash_method` | The hashing algorithm for passwords. Default `argon2id`. | | `rate_limit_login_ip` | Rate limit per IP address. `ttl` is in nanoseconds (600000000000 = 10 minutes). | | `rate_limit_login_identifier` | Rate limit per identifier (e.g. email). Same format as above. | | `password_compliancy` | Password requirements your users must meet. Adjust these values to match your security policy. | Create a user with an email identifier: ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/users \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "identifiers": [ { "type": "email_address", "value": "user@example.com" } ] }' ``` Set a password for the newly created user: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/users/${USER_ID}/password \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "password": "Prelude123*" }' ``` ## What's next? Now that your backend is configured, integrate the frontend using the [Web Integration](/session/documentation/frontend-sdks/web/password) guide and login the newly created user. # Enforce SSO login Source: https://docs.prelude.so/auth/documentation/integration-guide/saml/enforce Require allowlisted email domains to authenticate through SAML SSO. By default a SAML connection is *one of* several ways a user can sign in — they could still use OTP or a social provider with the same email. Enabling **enforce login** makes SAML the **only** way in for the domains the connection covers. ## How it works Enforcement is keyed off the connection's `email_domain_allowlist`. When `enforce_login` is `true`, any login attempt whose email domain resolves to that connection is redirected into SAML, and the other methods step aside: * **OTP create** — submitting an email identifier whose domain is enforced is refused with the [`saml_login_required`](#the-saml_login_required-error) error (HTTP `403`). The SDK is expected to restart the flow via the SAML initiate endpoint. * **OAuth callback** — when the IdP-supplied email's domain is enforced, the OAuth flow is abandoned (no account is created or linked) and the user is redirected into a fresh SAML `AuthnRequest`. The PKCE challenge and dispatch from the OAuth state are carried over. Enforcement is a gate layered on top of the existing flows, never a new way for them to fail. A domain that can't be resolved to exactly one enforcing connection — because it isn't allowlisted, or it's ambiguous — is simply **not enforced**, and the normal login methods proceed. `enforce_login` is inert without a non-empty `email_domain_allowlist`. The allowlist is the only domain→connection binding; with no domains, there is nothing to enforce. ## Enable enforcement Set `enforce_login` to `true` on the connection's behavior block. You can do this at creation or with a `PUT`: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/saml/okta/${CONNECTION_ID} \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "behavior": { "jit_provisioning": true, "allow_email_account_merge": true, "email_domain_allowlist": ["acme.com"], "enforce_login": true, "default_redirect_uri": "https://app.acme.com/callback" } }' ``` After this, a user with an `@acme.com` email can no longer obtain a session via OTP or OAuth — only through this Okta connection. ## The `saml_login_required` error When a client tries to start an OTP login for an enforced email, `POST /v1/session/otp` returns `403` with: ```json theme={null} { "code": "saml_login_required", "type": "forbidden", "message": "This email domain must sign in with SSO. Please use SAML to continue." } ``` This is the signal for your app to restart authentication through SAML. The Web SDK surfaces it as a typed `SAMLLoginRequiredError` so you can transparently fall back to `loginWithSAMLByEmail` — see [Enforce SSO login (Web SDK)](/session/documentation/frontend-sdks/web/saml-enforce). ## Notes and edge cases * **Ambiguous or unmatched domains are not enforced.** Connection creation rejects overlapping allowlists, so a domain resolves to at most one connection; an unresolvable domain falls through to the normal login flows. * **IdP-initiated logins are unaffected.** Enforcement only intercepts the OTP and OAuth entry points — a user clicking the IdP tile always lands on the ACS endpoint. * **Existing sessions are not revoked.** Enforcement governs new logins; it does not invalidate sessions a user already holds. ## What's next? Handle saml\_login\_required and fall back to SAML in the browser. Start a standard SAML login from the JavaScript SDK. # Google Workspace Source: https://docs.prelude.so/auth/documentation/integration-guide/saml/google Configure a Google Workspace custom SAML app for your Auth application. This guide walks you through connecting a Google Workspace custom SAML application to Prelude Auth. Google is the Identity Provider (IdP); Prelude Auth is the Service Provider (SP). ## Prerequisites * A [Google Workspace](https://workspace.google.com/) account with super admin access * A verified [custom domain](/session/documentation/domain-names) on your Auth application Because the SP endpoints embed the generated connection ID, the flow is: start the Google SAML app to obtain its metadata, create the Prelude connection from that metadata, then paste the generated SP values back into Google. ## Configure Google Workspace SAML 1. Open the [Google Admin Console](https://admin.google.com/) 2. Go to **Apps** > **Web and mobile apps** 3. Click **Add app** > **Add custom SAML app** 4. Enter an app name (e.g. "Prelude Auth") and click **Continue** 5. On the **Google Identity Provider details** screen, click **Download metadata** (or copy the **SSO URL**, **Entity ID**, and **Certificate**). Click **Continue**. Create the connection from Google's metadata. Start it **disabled** — you will enable it once the SP URLs are wired back into Google. If you downloaded the metadata XML, base64-encode it and pass it as `idp_metadata_xml`: ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/saml/google \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Google Workspace", "enabled": false, "idp_metadata_xml": "'"$(base64 -w0 GoogleIDPMetadata.xml)"'", "behavior": { "jit_provisioning": true, "allow_email_account_merge": true, "email_domain_allowlist": ["acme.com"], "enforce_login": false, "default_redirect_uri": "https://app.acme.com/callback" } }' ``` Alternatively, supply the IdP values explicitly instead of the metadata XML: ```json theme={null} "idp": { "entity_id": "https://accounts.google.com/o/saml2?idpid=C01abc234", "sso_url": "https://accounts.google.com/o/saml2/idp?idpid=C01abc234", "certificates": ["-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"] } ``` | Field | Description | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | A human-readable label for the connection. | | `enabled` | Set to `false` while you finish IdP setup; flip to `true` at the end. | | `idp_metadata_xml` | Base64-encoded (or raw) IdP metadata XML downloaded from Google. Provide exactly one IdP source. | | `idp` | Explicit IdP block — an alternative to `idp_metadata_xml`. | | `behavior.*` | Provisioning and enforcement options — see the [Introduction](/session/documentation/integration-guide/saml/introduction#connection-behavior). | The response contains an `sp` block with the values you need next (`sp.entity_id` and `sp.acs_url`). Back in the Google Admin Console, on the **Service provider details** screen: 1. Set **ACS URL** to the `sp.acs_url` from the response 2. Set **Entity ID** to the `sp.entity_id` from the response 3. Set **Name ID format** to `EMAIL` 4. Set **Name ID** to **Basic Information > Primary email** 5. Click **Continue** On the **Attributes** screen, map Google directory fields to the attribute names Prelude expects. The Google provider defaults to snake\_case names, so map: | Google directory field | App attribute | | ---------------------- | ------------- | | First name | `first_name` | | Last name | `last_name` | | Primary email | `email` | Click **Finish**. If you use different attribute names on the Google side, override them in the connection's `mapping` block via a `PUT` request. 1. In Google, open **User access** for the app and turn it **ON** for the relevant organizational units. 2. Enable the Prelude connection: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/saml/google/${CONNECTION_ID} \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "enabled": true }' ``` ## Delete the connection ```bash theme={null} curl -X DELETE https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/saml/google/${CONNECTION_ID} \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" ``` Existing `saml:` user identifiers are retained so historical sessions stay auditable. ## What's next? Now that the Google Workspace connection is configured, integrate the frontend using the [Web Integration](/session/documentation/frontend-sdks/web/saml) guide, or require this domain to use SSO with [Enforce SSO login](/session/documentation/integration-guide/saml/enforce). # Introduction Source: https://docs.prelude.so/auth/documentation/integration-guide/saml/introduction Configure SAML 2.0 single sign-on (SSO) for your Auth application. SAML SSO lets your users authenticate through their organization's Identity Provider (IdP), such as Okta, Google Workspace, or JumpCloud. Prelude Auth acts as the SAML Service Provider (SP): it consumes signed assertions from the IdP, provisions or links the matching user, and issues a session. ## Prerequisites Before you start, make sure you have: * A Prelude account with access to Prelude Auth * An **Application ID** (`appID`) — see [Applications](/session/documentation/applications) * Your **Management API key** for backend calls * A verified [custom domain](/session/documentation/domain-names) — SAML Service Provider URLs are anchored to your domain * Admin access to the Identity Provider you want to connect ## Supported providers | Provider | Identifier | | ---------------- | ----------- | | Okta | `okta` | | Google Workspace | `google` | | JumpCloud | `jumpcloud` | ## How the flow works Prelude Auth supports both flows defined by SAML 2.0: * **SP-initiated** — your app calls a SAML initiate endpoint, the SDK redirects the user to the IdP, and the IdP posts a signed `SAMLResponse` back to the connection's Assertion Consumer Service (ACS) URL. The flow is bound with [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) so the resulting `challenge_token` can only be finalized by the browser that started the flow. * **IdP-initiated** — the user clicks the application tile in the IdP dashboard and the IdP posts directly to the ACS URL. No `RelayState` is involved. Both flows converge on the ACS endpoint and redirect back to your app with a `challenge_token` you finalize the same way as [social login](/session/documentation/frontend-sdks/web/social-login). ## Service Provider endpoints When you create a connection, Prelude Auth derives a unique set of SP endpoints from your domain, the provider identifier, and the generated connection ID: | Endpoint | Pattern | | ---------------------------- | --------------------------------------------------------------------------- | | Entity ID (Audience URI) | `https://${YOUR_DOMAIN}/v1/session/login/saml/${PROVIDER}/${CONNECTION_ID}` | | ACS URL (Single sign-on URL) | `…/${CONNECTION_ID}/acs` | | Metadata URL | `…/${CONNECTION_ID}/metadata` | These values are returned in the `sp` block of the connection response and are **immutable** — they are what you register on the IdP side. Because the SP endpoints embed the connection ID, you create the connection in Prelude **first**, then copy the generated `sp` values into your IdP application. The provider guides below walk through the exact ordering. ## Connection behavior Every connection carries a `behavior` block that controls provisioning and login enforcement: | Field | Type | Description | | --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `jit_provisioning` | boolean | When `true`, just-in-time provisions a new user on first SSO login. | | `allow_email_account_merge` | boolean | When `true`, links the SAML identifier to an existing user that owns the same verified email. | | `email_domain_allowlist` | string\[] | Email domains this connection covers. This is the only domain→connection binding, and is required for email-resolved login and for `enforce_login`. | | `enforce_login` | boolean | When `true`, emails in the allowlist must sign in through SAML — OTP and OAuth are refused for them. See [Enforce SSO login](/session/documentation/integration-guide/saml/enforce). | | `default_redirect_uri` | string | Redirect URI used for IdP-initiated logins and when an SP-initiated flow omits `redirect_uri`. | | `sync_profile_on_login` | boolean | When `true`, refreshes `given_name`, `family_name`, and the assertion's groups (stored under `samlgroups`) from the IdP on every SSO login instead of only at first provisioning. The IdP becomes the source of truth for these fields and may overwrite values changed elsewhere. | ## Attribute mapping The `mapping` block maps assertion attributes to user profile fields (`email`, `first_name`, `last_name`, `groups`, plus arbitrary `custom` claims). Sensible per-provider defaults are applied when you omit it — Google Workspace, for example, defaults to the snake\_case attribute names it recommends. The mapped `groups` attribute is persisted to the user's profile under `samlgroups` only when [`sync_profile_on_login`](#connection-behavior) is enabled. ## Provider guides Configure an Okta SAML application. Configure a Google Workspace custom SAML app. Configure a JumpCloud custom SAML application. ## What's next? Require allowlisted email domains to authenticate through SAML. Start a SAML login from the JavaScript SDK. # JumpCloud Source: https://docs.prelude.so/auth/documentation/integration-guide/saml/jumpcloud Configure a JumpCloud SAML application for your Auth application. This guide walks you through connecting a JumpCloud custom SAML application to Prelude Auth. JumpCloud is the Identity Provider (IdP); Prelude Auth is the Service Provider (SP). ## Prerequisites * A [JumpCloud](https://jumpcloud.com/) account with admin access * A verified [custom domain](/session/documentation/domain-names) on your Auth application Because the SP endpoints embed the generated connection ID, the flow is: create the JumpCloud app with placeholder SP values, export JumpCloud's IdP details, create the Prelude connection, then paste the generated SP values back into JumpCloud. ## Configure JumpCloud SAML 1. Log in to the [JumpCloud Admin Portal](https://console.jumpcloud.com/) 2. Navigate to **SSO Applications** and click **+ Add New Application** 3. Choose **Custom Application**, then select **Manage Single Sign-On (SSO)** with **Configure SSO with SAML** 4. On the **SSO** tab, enter temporary placeholders for now — you will replace them in a later step: * **SP Entity ID**: `https://example.com` * **ACS URL**: `https://example.com/acs` 5. Set **SAMLSubject NameID** to `email` and **SAMLSubject NameID Format** to `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress` 6. Under **Attributes**, add the user attributes you want in the assertion — typically `email`, `firstName`, and `lastName` (these match Prelude's default attribute mapping) 7. Click **Save** (and **Continue to Application** if prompted) On the application's **SSO** tab, collect the IdP values Prelude needs: * **IdP Entity ID** — JumpCloud's issuer, e.g. `https://sso.jumpcloud.com/saml2/${APP_ID}` * **IdP URL** (SSO URL) — where Prelude sends SP-initiated requests * **IdP Certificate** — click **Export Metadata** / download the certificate (PEM, `-----BEGIN CERTIFICATE-----`) You will pass these to Prelude in the next step. Create the connection from JumpCloud's IdP details. Start it **disabled** — you will enable it once the SP URLs are wired back into JumpCloud. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/saml/jumpcloud \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme JumpCloud", "enabled": false, "idp": { "entity_id": "https://sso.jumpcloud.com/saml2/${APP_ID}", "sso_url": "https://sso.jumpcloud.com/saml2/${APP_ID}", "certificates": ["-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"] }, "behavior": { "jit_provisioning": true, "allow_email_account_merge": true, "email_domain_allowlist": ["acme.com"], "enforce_login": false, "default_redirect_uri": "https://app.acme.com/callback" } }' ``` | Field | Description | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | A human-readable label for the connection. | | `enabled` | Set to `false` while you finish IdP setup; flip to `true` at the end. | | `idp.entity_id` | JumpCloud's IdP Entity ID (issuer). | | `idp.sso_url` | JumpCloud's IdP URL (SSO URL). | | `idp.certificates` | JumpCloud's signing certificate(s), PEM-encoded. Provide exactly one IdP source — the explicit `idp` block, `idp_metadata_url`, or `idp_metadata_xml`. | | `behavior.jit_provisioning` | When `true`, creates a user on first SSO login. | | `behavior.allow_email_account_merge` | When `true`, links to an existing user with the same verified email. | | `behavior.email_domain_allowlist` | Domains this connection covers; required for email-resolved login and `enforce_login`. | | `behavior.default_redirect_uri` | Redirect URI for IdP-initiated logins and when `redirect_uri` is omitted. | The response contains an `sp` block with the values you need next: ```json theme={null} { "connection": { "id": "samlc_01jqebhswje1ka1z7ahr9rfsgt", "provider_id": "jumpcloud", "sp": { "entity_id": "https://session.acme.com/v1/session/login/saml/jumpcloud/samlc_01jqebhswje1ka1z7ahr9rfsgt", "acs_url": "https://session.acme.com/v1/session/login/saml/jumpcloud/samlc_01jqebhswje1ka1z7ahr9rfsgt/acs", "metadata_url": "https://session.acme.com/v1/session/login/saml/jumpcloud/samlc_01jqebhswje1ka1z7ahr9rfsgt/metadata" } } } ``` Return to the JumpCloud application's **SSO** tab and edit the SAML settings: 1. Set **ACS URL** to the `sp.acs_url` from the response 2. Set **SP Entity ID** to the `sp.entity_id` from the response 3. Click **Save** The values must match exactly — no trailing slash, and `https` only. 1. On the JumpCloud application's **User Groups** tab, assign the groups who should have access. 2. Enable the Prelude connection: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/saml/jumpcloud/samlc_01jqebhswje1ka1z7ahr9rfsgt \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "enabled": true }' ``` ## Rotating the IdP certificate When JumpCloud rotates its signing certificate, update the connection's IdP block (the Entity ID is immutable — to change it, delete and recreate the connection): ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/saml/jumpcloud/${CONNECTION_ID} \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "idp": { "sso_url": "https://sso.jumpcloud.com/saml2/abc", "certificates": ["-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"] } }' ``` ## Delete the connection ```bash theme={null} curl -X DELETE https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/saml/jumpcloud/${CONNECTION_ID} \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" ``` Existing `saml:` user identifiers are retained so historical sessions stay auditable. ## What's next? Now that the JumpCloud connection is configured, integrate the frontend using the [Web Integration](/session/documentation/frontend-sdks/web/saml) guide, or require this domain to use SSO with [Enforce SSO login](/session/documentation/integration-guide/saml/enforce). # Okta Source: https://docs.prelude.so/auth/documentation/integration-guide/saml/okta Configure an Okta SAML application for your Auth application. This guide walks you through connecting an Okta SAML application to Prelude Auth. Okta is the Identity Provider (IdP); Prelude Auth is the Service Provider (SP). ## Prerequisites * An [Okta](https://www.okta.com/) account with admin access * A verified [custom domain](/session/documentation/domain-names) on your Auth application Because the SP endpoints embed the generated connection ID, the flow is: create the Okta app, create the Prelude connection from Okta's metadata, then paste the generated SP values back into Okta. ## Configure Okta SAML 1. Log in to the [Okta Admin Console](https://login.okta.com/) 2. Navigate to **Applications** > **Applications** 3. Click **Create App Integration** 4. Select **SAML 2.0** as the sign-in method, then click **Next** 5. Enter a name (e.g. "Prelude Auth") and click **Next** 6. On the **Configure SAML** screen, enter temporary placeholders for now — you will replace them in a later step: * **Single sign-on URL**: `https://example.com/acs` * **Audience URI (SP Entity ID)**: `https://example.com` 7. Set **Name ID format** to `EmailAddress` and **Application username** to `Email` 8. Click **Next**, then **Finish** On the application's **Sign On** tab, find the **Metadata URL** (under *SAML Signing Certificates* / *More details*). It looks like: ``` https://${YOUR_OKTA_DOMAIN}/app/${APP_ID}/sso/saml/metadata ``` You will pass this URL to Prelude in the next step so the IdP Entity ID, SSO URL, and signing certificate are imported automatically. Create the connection from Okta's metadata. Start it **disabled** — you will enable it once the SP URLs are wired back into Okta. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/saml/okta \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Okta", "enabled": false, "idp_metadata_url": "https://${YOUR_OKTA_DOMAIN}/app/${APP_ID}/sso/saml/metadata", "behavior": { "jit_provisioning": true, "allow_email_account_merge": true, "email_domain_allowlist": ["acme.com"], "enforce_login": false, "default_redirect_uri": "https://app.acme.com/callback" } }' ``` | Field | Description | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | | `name` | A human-readable label for the connection. | | `enabled` | Set to `false` while you finish IdP setup; flip to `true` at the end. | | `idp_metadata_url` | Okta's SAML metadata URL. Provide exactly one IdP source (`idp_metadata_url`, `idp_metadata_xml`, or an explicit `idp` block). | | `behavior.jit_provisioning` | When `true`, creates a user on first SSO login. | | `behavior.allow_email_account_merge` | When `true`, links to an existing user with the same verified email. | | `behavior.email_domain_allowlist` | Domains this connection covers; required for email-resolved login and `enforce_login`. | | `behavior.default_redirect_uri` | Redirect URI for IdP-initiated logins and when `redirect_uri` is omitted. | The response contains an `sp` block with the values you need next: ```json theme={null} { "connection": { "id": "samlc_01jqebhswje1ka1z7ahr9rfsgt", "provider_id": "okta", "sp": { "entity_id": "https://session.acme.com/v1/session/login/saml/okta/samlc_01jqebhswje1ka1z7ahr9rfsgt", "acs_url": "https://session.acme.com/v1/session/login/saml/okta/samlc_01jqebhswje1ka1z7ahr9rfsgt/acs", "metadata_url": "https://session.acme.com/v1/session/login/saml/okta/samlc_01jqebhswje1ka1z7ahr9rfsgt/metadata" } } } ``` Return to the Okta application's **General** tab and **Edit** the SAML settings: 1. Set **Single sign-on URL** to the `sp.acs_url` from the response 2. Set **Audience URI (SP Entity ID)** to the `sp.entity_id` from the response 3. Click **Save** The values must match exactly — no trailing slash, and `https` only. 1. On the Okta **Assignments** tab, assign the people or groups who should have access. 2. Enable the Prelude connection: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/saml/okta/samlc_01jqebhswje1ka1z7ahr9rfsgt \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "enabled": true }' ``` ## Rotating the IdP certificate When Okta rotates its signing certificate, update the connection's IdP block (the Entity ID is immutable — to change it, delete and recreate the connection): ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/saml/okta/${CONNECTION_ID} \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "idp": { "sso_url": "https://acme.okta.com/app/abc/exk.../sso/saml", "certificates": ["-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"] } }' ``` ## Delete the connection ```bash theme={null} curl -X DELETE https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/saml/okta/${CONNECTION_ID} \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" ``` Existing `saml:` user identifiers are retained so historical sessions stay auditable. ## What's next? Now that the Okta connection is configured, integrate the frontend using the [Web Integration](/session/documentation/frontend-sdks/web/saml) guide, or require this domain to use SSO with [Enforce SSO login](/session/documentation/integration-guide/saml/enforce). # Apple Source: https://docs.prelude.so/auth/documentation/integration-guide/social-login/apple Configure Apple OAuth for your Auth application. This guide walks you through configuring Apple as a social login provider for your application. ## Prerequisites * An [Apple Developer account](https://developer.apple.com/) * An **App ID** registered for your application ## Configure Apple OAuth If you don't already have an App ID for your application: 1. Go to the [Apple Developer Portal](https://developer.apple.com/account/resources/identifiers/list) 2. Click **Identifiers** > **+** to register a new identifier 3. Select **App IDs** and click **Continue** 4. Select **App** as the type and click **Continue** 5. Enter a description and a **Bundle ID** (e.g. `com.yourapp`) 6. Under **Capabilities**, enable **Sign in with Apple** 7. Click **Register** The Services ID is used as the `client_id` when configuring the OAuth provider. 1. Go to the [Apple Developer Portal](https://developer.apple.com/account/resources/identifiers/list/serviceId) 2. Click **Identifiers** > **+** to register a new identifier 3. Select **Services IDs** and click **Continue** 4. Enter a description (e.g. "Prelude Auth") and an identifier (e.g. `com.yourapp.session`) — this identifier will be your `client_id` 5. Click **Register** 1. Click on the newly created Services ID 2. Enable **Sign in with Apple** and click **Configure** 3. Select the **App ID** you created in the first step as the Primary App ID 4. Under **Domains and Subdomains**, add your [custom domain](/auth/documentation/domain-names) (e.g. `session.yourapp.com`) 5. Under **Return URLs**, add: ``` https://${YOUR_CUSTOM_DOMAIN}/v1/session/login/oauth/apple/callback ``` Replace `${YOUR_CUSTOM_DOMAIN}` with your custom domain. 6. Click **Save** and then **Continue** > **Register** The return URL must match exactly. Make sure there is no trailing slash and that you are using `https`. Apple does not provide a client secret directly. Instead, you create a private key that Prelude uses to generate the client secret automatically. 1. Go to **Keys** in the [Apple Developer Portal](https://developer.apple.com/account/resources/authkeys/list) 2. Click **+** to create a new key 3. Give it a name and enable **Sign in with Apple** 4. Click **Configure** and select the App ID you created in the first step 5. Click **Save**, then **Continue**, then **Register** 6. Download the `.p8` private key file — you can only download it once 7. Note the **Key ID** displayed on the page — you will need it in the next step ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/apple \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "com.yourapp.session", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true }, "apple": { "team_id": "YOUR_TEAM_ID", "key_id": "YOUR_KEY_ID", "p8_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" } }' ``` | Field | Description | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `client_id` | The **identifier** of your Services ID (e.g. `com.yourapp.session`). This is the identifier you chose when creating the Services ID, not the App ID. | | `enabled` | Set to `true` to enable Apple login. | | `apple.team_id` | Your Apple Developer Team ID (found in the top-right of the Apple Developer Portal). | | `apple.key_id` | The Key ID of the private key you created. | | `apple.p8_key` | The contents of the `.p8` private key file. **Each line break must be replaced by `\n`** so the entire key is a single-line string (e.g. `"-----BEGIN PRIVATE KEY-----\nMIGT....\n-----END PRIVATE KEY-----"`). Prelude uses this to generate the client secret automatically. | | `options.use_email_as_identifier` | When `true`, the user's Apple email is stored as an email identifier. | | `options.allow_email_account_merge` | When `true`, if a user with the same email already exists, the Apple account is linked to the existing user. | ## Update the configuration To update an existing Apple OAuth configuration: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/apple \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "com.yourapp.session", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true }, "apple": { "team_id": "YOUR_TEAM_ID", "key_id": "YOUR_KEY_ID", "p8_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" } }' ``` ## Delete the configuration To remove Apple OAuth from your application: ```bash theme={null} curl -X DELETE https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/apple \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" ``` ## What's next? Now that Apple OAuth is configured on your backend, integrate the frontend using the [Web Integration](/session/documentation/frontend-sdks/web/social-login) guide. # Facebook Source: https://docs.prelude.so/auth/documentation/integration-guide/social-login/facebook Configure Facebook OAuth for your Auth application. This guide walks you through configuring Facebook as a social login provider for your application. ## Prerequisites * A [Meta for Developers](https://developers.facebook.com/) account * Your Facebook **App ID** (client ID) and **App Secret** (client secret) ## Configure Facebook OAuth 1. Go to [Meta for Developers](https://developers.facebook.com/apps/) 2. Click **Create App** 3. Select a use case that includes **Facebook Login** (for example, "Authenticate and request data from users with Facebook Login") 4. Fill in the app details and finish the creation flow 5. In the app dashboard, add the **Facebook Login** product if it is not already added 6. Navigate to **App settings > Basic** to find your **App ID** and **App Secret** — you will need them in the next step In your Facebook app dashboard: 1. Go to **Facebook Login > Settings** 2. Under **Valid OAuth Redirect URIs**, add: ``` https://${YOUR_CUSTOM_DOMAIN}/v1/session/login/oauth/facebook/callback ``` Replace `${YOUR_CUSTOM_DOMAIN}` with your [custom domain](/session/documentation/domain-names) (e.g. `session.yourapp.com`). 3. Click **Save changes** The redirect URI must match exactly. Make sure there is no trailing slash and that you are using `https`. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/facebook \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "your-facebook-app-id", "client_secret": "your-facebook-app-secret", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true } }' ``` | Field | Description | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `client_id` | Your Facebook App ID. | | `client_secret` | Your Facebook App Secret. | | `enabled` | Set to `true` to enable Facebook login. | | `options.use_email_as_identifier` | When `true`, the user's Facebook email is stored as an email identifier. | | `options.allow_email_account_merge` | When `true`, if a user with the same email already exists, the Facebook account is linked to the existing user. | Facebook only returns an email when the user grants the `email` permission and their Facebook account has a verified email address. Users without a verified email on Facebook will sign in without an email identifier. ## Update the configuration To update an existing Facebook OAuth configuration: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/facebook \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "your-new-facebook-app-id", "client_secret": "your-new-facebook-app-secret", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true } }' ``` ## Delete the configuration To remove Facebook OAuth from your application: ```bash theme={null} curl -X DELETE https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/facebook \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" ``` ## What's next? Now that Facebook OAuth is configured on your backend, integrate the frontend using the [Web Integration](/session/documentation/frontend-sdks/web/social-login) guide. # GitHub Source: https://docs.prelude.so/auth/documentation/integration-guide/social-login/github Configure GitHub OAuth for your Auth application. This guide walks you through configuring GitHub as a social login provider for your application. ## Prerequisites * A [GitHub](https://github.com/) account * Your GitHub OAuth **Client ID** and **Client Secret** ## Configure GitHub OAuth 1. Go to [GitHub Developer Settings](https://github.com/settings/developers) 2. Click **OAuth Apps** > **New OAuth App** 3. Enter an **Application name** (e.g. "Prelude Auth") 4. Enter the **Homepage URL** of your application 5. Set the **Authorization callback URL** to: ``` https://${YOUR_CUSTOM_DOMAIN}/v1/session/login/oauth/github/callback ``` Replace `${YOUR_CUSTOM_DOMAIN}` with your [custom domain](/session/documentation/domain-names) (e.g. `session.yourapp.com`). 6. Click **Register application** 7. Copy the **Client ID** 8. Click **Generate a new client secret** and copy the **Client Secret** The callback URL must match exactly. Make sure there is no trailing slash and that you are using `https`. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/github \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "your-github-client-id", "client_secret": "your-github-client-secret", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true } }' ``` | Field | Description | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `client_id` | Your GitHub OAuth client ID. | | `client_secret` | Your GitHub OAuth client secret. | | `enabled` | Set to `true` to enable GitHub login. | | `options.use_email_as_identifier` | When `true`, the user's GitHub email is stored as an email identifier. | | `options.allow_email_account_merge` | When `true`, if a user with the same email already exists, the GitHub account is linked to the existing user. | ## Update the configuration To update an existing GitHub OAuth configuration: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/github \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "your-new-github-client-id", "client_secret": "your-new-github-client-secret", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true } }' ``` ## Delete the configuration To remove GitHub OAuth from your application: ```bash theme={null} curl -X DELETE https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/github \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" ``` ## What's next? Now that GitHub OAuth is configured on your backend, integrate the frontend using the [Web Integration](/session/documentation/frontend-sdks/web/social-login) guide. # Google Source: https://docs.prelude.so/auth/documentation/integration-guide/social-login/google Configure Google OAuth for your Auth application. This guide walks you through configuring Google as a social login provider for your application. ## Prerequisites * A [Google Cloud project](https://console.cloud.google.com/) * Your Google OAuth **Client ID** and **Client Secret** ## Configure Google OAuth 1. Go to the [Google Cloud Console](https://console.cloud.google.com/) 2. Select your project (or create a new one) 3. Navigate to **APIs & Services > Credentials** 4. Click **Create Credentials > OAuth client ID** 5. Select **Web application** as the application type 6. Give it a name (e.g. "Prelude Auth") 7. Copy the **Client ID** and **Client Secret** — you will need them in the next step Still on the same OAuth client page in the Google Cloud Console: 1. Under **Authorized redirect URIs**, click **Add URI** 2. Enter the following URI: ``` https://${YOUR_CUSTOM_DOMAIN}/v1/session/login/oauth/google/callback ``` Replace `${YOUR_CUSTOM_DOMAIN}` with your [custom domain](/session/documentation/domain-names) (e.g. `session.yourapp.com`). 3. Click **Save** The redirect URI must match exactly. Make sure there is no trailing slash and that you are using `https`. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/google \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "your-google-client-id", "client_secret": "your-google-client-secret", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true } }' ``` | Field | Description | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `client_id` | Your Google OAuth client ID. | | `client_secret` | Your Google OAuth client secret. | | `enabled` | Set to `true` to enable Google login. | | `options.use_email_as_identifier` | When `true`, the user's Google email is stored as an email identifier. | | `options.allow_email_account_merge` | When `true`, if a user with the same email already exists, the Google account is linked to the existing user. | ## Update the configuration To update an existing Google OAuth configuration: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/google \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "your-new-google-client-id", "client_secret": "your-new-google-client-secret", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true } }' ``` ## Delete the configuration To remove Google OAuth from your application: ```bash theme={null} curl -X DELETE https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/google \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" ``` ## What's next? Now that Google OAuth is configured on your backend, integrate the frontend using the [Web Integration](/session/documentation/frontend-sdks/web/social-login) guide. # Introduction Source: https://docs.prelude.so/auth/documentation/integration-guide/social-login/introduction Configure social login providers for your Auth application. Social login allows users to authenticate with their existing accounts from providers like Google, Apple, Microsoft, GitHub, Okta, Facebook, and LinkedIn. ## Prerequisites Before you start, make sure you have: * A Prelude account with access to Prelude Auth * An **Application ID** (`appID`) — see [Applications](/session/documentation/applications) * Your **Management API key** for backend calls * OAuth credentials from the provider you want to configure (client ID, client secret, etc.) ## Supported providers | Provider | Identifier | | --------- | ----------- | | Google | `google` | | Apple | `apple` | | Microsoft | `microsoft` | | GitHub | `github` | | Okta | `okta` | | Facebook | `facebook` | | LinkedIn | `linkedin` | ## Configuration options All providers share the following options: | Field | Type | Description | | ----------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `client_id` | string | OAuth client ID from the provider. | | `client_secret` | string | OAuth client secret from the provider. | | `enabled` | boolean | Set to `true` to enable the provider. | | `scopes` | string\[] | Scopes requested from the provider (IdP), for example `["openid", "email", "profile"]`. | | `granted_scopes` | string\[] | Prelude session scopes attached to the session when a login completes through this provider, for example `["prld:pwd:write"]`. Distinct from `scopes`, which are requested from the IdP. Defaults to an empty list. | | `options.use_email_as_identifier` | boolean | When `true`, creates an email identifier for new OAuth users. Only applied when the email is verified — either by the provider, or via the `verify_email` OTP flow. | | `options.allow_email_account_merge` | boolean | When `true`, allows merging accounts that share the same verified email address. | | `options.verify_email` | boolean | When `true`, falls back to an email OTP challenge when the provider returns an unverified email. See [Verify email via OTP](#verify-email-via-otp) below. Requires `use_email_as_identifier=true` and at least one email OTP login config; both are enforced at write time. | Some providers require additional configuration — see the provider-specific guides below. ## Email verification trust model `use_email_as_identifier` and `allow_email_account_merge` both depend on whether the provider vouches that the email belongs to the signed-in user. Prelude Auth uses provider-specific signals: | Provider | Verification signal | Notes | | --------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Google | `email_verified` claim in ID token | Verified by default. | | Apple | `email_verified` claim in ID token | Verified by default. | | Microsoft | `xms_edov` optional claim | **Not verified by default.** Customers must enable the `xms_edov` optional claim in Azure Token Configuration — otherwise the email is treated as unverified and auto-merge is disabled. See the [Microsoft guide](/session/documentation/integration-guide/social-login/microsoft) for setup steps and background on the [nOAuth](https://www.descope.com/blog/post/noauth) vulnerability this prevents. | | GitHub | Primary + verified email from the `/user/emails` API | Verified by default. | | Okta | `email_verified` claim in ID token | Depends on your Okta tenant configuration. | | Facebook | Returned email from the Graph API | Verified by default. | | LinkedIn | `email_verified` claim from the OpenID Connect userinfo endpoint | Verified by default. | When a provider surfaces an email as unverified, Prelude Auth will not store it as an identifier and will not auto-merge it into an existing account. If `allow_email_account_merge` is enabled and an unverified email collides with an existing Auth user, the flow is rejected with an `email_in_use` error instead of silently linking the accounts. ## Verify email via OTP Enable `verify_email` on a provider when you want users to keep signing in even if the IdP returns the email as unverified. Instead of rejecting the flow or creating an unlinked account, Prelude Auth sends an email OTP to the address surfaced by the IdP and links the OAuth identifier only after the user proves ownership. The flow: 1. The user signs in with the OAuth provider. 2. The IdP returns an email but does not vouch for it (e.g. Microsoft without `xms_edov`, or a custom Okta tenant). 3. Prelude Auth redirects back to your app with `challenge_token=…&status=otp_required` instead of finalizing the login. 4. The Web SDK's `finalizeOAuthLogin` returns `{ status: "otp_required", challengeId, email }` so your app can route to its OTP screen and call `checkOTP` with the code the user entered. 5. On a successful OTP, Prelude Auth attaches the OAuth identifier to (or creates) the user that owns the email and finalizes the login automatically. Preconditions enforced when you save the OAuth config: * `use_email_as_identifier` must be `true`. Verifying an email that will never be persisted as an identifier serves no purpose. * The application must have at least one email OTP login config. Removing the last email OTP login config while any provider still has `verify_email=true` is rejected. If `verify_email` is `true` but `use_email_as_identifier` is later flipped off, the OTP step is skipped at runtime and the account is created with only the OAuth identifier — the existing config keeps loading instead of breaking, but the OAuth-link flow is effectively disabled until both flags are aligned. When the email already belongs to another account and `allow_email_account_merge` is `false`, Prelude Auth rejects with `email_in_use` *before* sending the OTP so the user does not get sent through a screen for a flow that would dead-end. ## Provider guides Configure Google OAuth for your application. Configure Apple OAuth for your application. Configure Microsoft OAuth for your application. Configure GitHub OAuth for your application. Configure Okta OAuth for your application. Configure Facebook OAuth for your application. Configure LinkedIn OAuth for your application. # LinkedIn Source: https://docs.prelude.so/auth/documentation/integration-guide/social-login/linkedin Configure LinkedIn OAuth for your Auth application. This guide walks you through configuring LinkedIn as a social login provider for your application. ## Prerequisites * A [LinkedIn Developer](https://www.linkedin.com/developers/) account * Your LinkedIn OAuth **Client ID** and **Client Secret** ## Configure LinkedIn OAuth 1. Go to [LinkedIn Developers](https://www.linkedin.com/developers/apps) 2. Click **Create app** 3. Fill in the app details (name, associated LinkedIn Page, logo) and finish the creation flow 4. Open the **Products** tab and add **Sign In with LinkedIn using OpenID Connect** 5. Navigate to the **Auth** tab to find your **Client ID** and **Client Secret** — you will need them in the next step Scopes (`openid`, `profile`, `email`) are granted by adding the **Sign In with LinkedIn using OpenID Connect** product — they cannot be added manually in the **OAuth 2.0 scopes** panel. Make sure you add the OpenID Connect product and not the legacy "Sign In with LinkedIn" product. In your LinkedIn app's **Auth** tab: 1. Under **OAuth 2.0 settings > Authorized redirect URLs for your app**, add: ``` https://${YOUR_CUSTOM_DOMAIN}/v1/session/login/oauth/linkedin/callback ``` Replace `${YOUR_CUSTOM_DOMAIN}` with your [custom domain](/session/documentation/domain-names) (e.g. `session.yourapp.com`). 2. Click **Update** The redirect URL must match exactly. Make sure there is no trailing slash and that you are using `https`. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/linkedin \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "your-linkedin-client-id", "client_secret": "your-linkedin-client-secret", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true } }' ``` | Field | Description | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `client_id` | Your LinkedIn OAuth client ID. | | `client_secret` | Your LinkedIn OAuth client secret. | | `enabled` | Set to `true` to enable LinkedIn login. | | `options.use_email_as_identifier` | When `true`, the user's LinkedIn email is stored as an email identifier. | | `options.allow_email_account_merge` | When `true`, if a user with the same email already exists, the LinkedIn account is linked to the existing user. | LinkedIn uses "Sign In with LinkedIn using OpenID Connect", which requests the `openid`, `profile`, and `email` scopes by default. The email is returned as verified, so it can be used as an identifier and for account merging. ## Update the configuration To update an existing LinkedIn OAuth configuration: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/linkedin \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "your-new-linkedin-client-id", "client_secret": "your-new-linkedin-client-secret", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true } }' ``` ## Delete the configuration To remove LinkedIn OAuth from your application: ```bash theme={null} curl -X DELETE https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/linkedin \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" ``` ## What's next? Now that LinkedIn OAuth is configured on your backend, integrate the frontend using the [Web Integration](/session/documentation/frontend-sdks/web/social-login) guide. # Microsoft Source: https://docs.prelude.so/auth/documentation/integration-guide/social-login/microsoft Configure Microsoft OAuth for your Auth application. This guide walks you through configuring Microsoft as a social login provider for your application. ## Prerequisites * A [Microsoft Azure](https://portal.azure.com/) account * Your Microsoft OAuth **Application (client) ID** and **Client Secret** ## Configure Microsoft OAuth 1. Go to the [Azure Portal](https://portal.azure.com/) 2. Navigate to **Microsoft Entra ID** > **App registrations** 3. Click **New registration** 4. Enter a name (e.g. "Prelude Auth") 5. Under **Supported account types**, select the option that fits your needs (e.g. "Accounts in any organizational directory and personal Microsoft accounts") 6. Click **Register** 7. Copy the **Application (client) ID** — you will need it in the next step 1. In your app registration, go to **Manage** > **Authentication** 2. Click **Add a platform** > **Web** 3. Enter the following redirect URI: ``` https://${YOUR_CUSTOM_DOMAIN}/v1/session/login/oauth/microsoft/callback ``` Replace `${YOUR_CUSTOM_DOMAIN}` with your [custom domain](/session/documentation/domain-names) (e.g. `session.yourapp.com`). 4. Click **Configure** The redirect URI must match exactly. Make sure there is no trailing slash and that you are using `https`. 1. In your app registration, go to **Certificates & secrets** 2. Click **New client secret** 3. Enter a description and select an expiration period 4. Click **Add** 5. Copy the **Value** (not the Secret ID) — you will need it in the next step Microsoft does not emit a standard `email_verified` claim and the `email` value in a Microsoft ID token is **not** proof of mailbox ownership — a tenant admin can set any arbitrary email on a user they control. This is the [nOAuth](https://www.descope.com/blog/post/noauth) account-takeover vector. Prelude Auth treats a Microsoft email as verified **only** when the `xms_edov` (Email Domain Owner Verification) optional claim is present and `true`. You must enable this claim in Azure for `allow_email_account_merge` to work with Microsoft; otherwise every Microsoft login is treated as unverified and a new Auth user is created on each sign-in instead of merging. 1. In your app registration, go to **Manage** > **Token configuration** 2. Click **Add optional claim** 3. Select **ID** as the token type 4. Check **email** and **xms\_pdl** (required to trigger `xms_edov` emission), then click **Add** 5. When prompted, check **Turn on the Microsoft Graph email permission** and confirm 6. Click **Add optional claim** again, select **ID**, check **xms\_edov**, then click **Add** `xms_edov` is issued as `true` when the email's domain is verified by the tenant (enterprise users) or for personal Microsoft accounts where the email was verified at account creation. It is issued as `false` — or omitted entirely — when the email is user-editable or domain ownership cannot be proven. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/microsoft \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "your-microsoft-client-id", "client_secret": "your-microsoft-client-secret", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true } }' ``` | Field | Description | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `client_id` | Your Microsoft Application (client) ID. | | `client_secret` | Your Microsoft client secret value. | | `enabled` | Set to `true` to enable Microsoft login. | | `options.use_email_as_identifier` | When `true`, the user's Microsoft email is stored as an email identifier — **only** when the email is verified via `xms_edov`. | | `options.allow_email_account_merge` | When `true`, an incoming Microsoft identity is linked to an existing Auth user whose email matches — **only** when the email is verified via `xms_edov`. An unverified email that collides with an existing user is rejected with an `email_in_use` error rather than auto-merged. | ## Security: email verification with Microsoft Unlike Google and Apple, Microsoft does not guarantee the `email` claim in its ID token is owned by the signed-in user. Any tenant admin can set an arbitrary email on a user in their tenant, and Microsoft does not emit an `email_verified` claim. Prelude Auth therefore relies on the `xms_edov` optional claim as the sole source of truth for Microsoft email verification: * `xms_edov` is **present and `true`** → Prelude Auth treats the Microsoft email as verified. `use_email_as_identifier` and `allow_email_account_merge` apply normally. * `xms_edov` is `false`, missing, or any other value → Prelude Auth treats the Microsoft email as **unverified**. No email identifier is stored, no auto-merge happens, and if `allow_email_account_merge` is enabled and the email collides with an existing Auth user the flow is rejected with `email_in_use` (protecting against the [nOAuth](https://www.descope.com/blog/post/noauth) takeover vector). If you skip the `xms_edov` configuration step above, Microsoft sign-in will still work but every Microsoft user will be created as a fresh Auth account and email-based account merge will never fire. ### Alternative: verify the email via OTP When you cannot enable `xms_edov` (e.g. you do not control the Azure app registration), set [`options.verify_email`](/session/documentation/integration-guide/social-login#verify-email-via-otp) to `true` instead. Prelude Auth will detect the unverified Microsoft email and challenge the user with an email OTP before linking the OAuth identifier — preserving the nOAuth protection while still letting users sign in. This requires `use_email_as_identifier=true` and at least one email OTP login config on the application. ## Update the configuration To update an existing Microsoft OAuth configuration: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/microsoft \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "your-new-microsoft-client-id", "client_secret": "your-new-microsoft-client-secret", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true } }' ``` ## Delete the configuration To remove Microsoft OAuth from your application: ```bash theme={null} curl -X DELETE https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/microsoft \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" ``` ## What's next? Now that Microsoft OAuth is configured on your backend, integrate the frontend using the [Web Integration](/session/documentation/frontend-sdks/web/social-login) guide. # Okta Source: https://docs.prelude.so/auth/documentation/integration-guide/social-login/okta Configure Okta OAuth for your Auth application. This guide walks you through configuring Okta as a social login provider for your application. ## Prerequisites * An [Okta](https://www.okta.com/) account with admin access * Your Okta **Client ID**, **Client Secret**, and **Issuer URL** ## Configure Okta OAuth 1. Log in to the [Okta Admin Console](https://login.okta.com/) 2. Navigate to **Applications** > **Applications** 3. Click **Create App Integration** 4. Select **OIDC - OpenID Connect** as the sign-in method 5. Select **Web Application** as the application type 6. Click **Next** 7. Enter a name (e.g. "Prelude Auth") 8. Under **Sign-in redirect URIs**, replace the default value with: ``` https://${YOUR_CUSTOM_DOMAIN}/v1/session/login/oauth/okta/callback ``` Replace `${YOUR_CUSTOM_DOMAIN}` with your [custom domain](/session/documentation/domain-names) (e.g. `session.yourapp.com`). 9. Click **Save** 10. Copy the **Client ID** and **Client Secret** from the application settings page The redirect URI must match exactly. Make sure there is no trailing slash and that you are using `https`. Your Issuer URL is found in **Security** > **API** > **Authorization Servers** in the Okta Admin Console (e.g. `https://dev-123456.okta.com/oauth2/default`). You will need it in the next step. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/okta \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "your-okta-client-id", "client_secret": "your-okta-client-secret", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true }, "okta": { "issuer_url": "https://dev-123456.okta.com/oauth2/default" } }' ``` | Field | Description | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `client_id` | Your Okta OAuth client ID. | | `client_secret` | Your Okta OAuth client secret. | | `enabled` | Set to `true` to enable Okta login. | | `okta.issuer_url` | Your Okta authorization server Issuer URL (e.g. `https://dev-123456.okta.com/oauth2/default`). | | `options.use_email_as_identifier` | When `true`, the user's Okta email is stored as an email identifier. | | `options.allow_email_account_merge` | When `true`, if a user with the same email already exists, the Okta account is linked to the existing user. | ## Update the configuration To update an existing Okta OAuth configuration: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/okta \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_id": "your-new-okta-client-id", "client_secret": "your-new-okta-client-secret", "enabled": true, "options": { "use_email_as_identifier": true, "allow_email_account_merge": true }, "okta": { "issuer_url": "https://dev-123456.okta.com/oauth2/default" } }' ``` ## Delete the configuration To remove Okta OAuth from your application: ```bash theme={null} curl -X DELETE https://api.prelude.dev/v2/session/apps/${APP_ID}/config/login/oauth/okta \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" ``` ## What's next? Now that Okta OAuth is configured on your backend, integrate the frontend using the [Web Integration](/session/documentation/frontend-sdks/web/social-login) guide. # SSO Login Source: https://docs.prelude.so/auth/documentation/integration-guide/sso Add single sign-on across your own applications by using Prelude Auth as the OAuth 2.0 authorization server, then sign clients in with the Web SDK. Turn Prelude Auth into your own OAuth 2.0 authorization server so any of your applications can sign users in against your existing Prelude Auth users — a "Sign in with us" button for your product suite. You host the login and consent screens once; every client app then runs a standard authorization-code + PKCE flow against them, with no extra identity provider to run. This is the same authorization-server machinery that powers [MCP Login](/auth/documentation/integration-guide/mcp), pointed at your **own** applications instead of MCP clients. This is **not** [Enterprise SSO (OIDC)](/auth/documentation/frontend-sdks/web/enterprise-oidc) or [social login](/auth/documentation/integration-guide/social-login/introduction). Those make Prelude the OAuth **client** of an external identity provider (Okta, Google…). Here Prelude is the authorization **server**, and your app is the client. ## How it works There are two sides to build, and this guide covers both: * **The authorization frontend** — the login and consent screens Prelude redirects to. Prelude drives the OAuth protocol; your UI authenticates the user and collects consent, then hands the browser back with an authorization `code`. This is exactly what the Prelude Dashboard does for MCP clients today. * **The client app** — the application asking users to sign in. It uses the new `PrldOAuth2Client` in the [Web SDK](/auth/documentation/frontend-sdks/web/introduction) to start the flow and exchange the `code` for a session. A login looks like this: The client app calls `PrldOAuth2Client.initiate()` and redirects the browser to `/v1/session/oauth/authorize` with PKCE. Prelude persists the request and `302`s to your login UI with an `?oauth_req=` parameter. Your sign-in route reads `oauth_req`, authenticates the user with any method your app supports (OTP, password, passkey, social, SAML), then calls `continueOAuth2(oauthReq)`. Prelude returns the consent URL to navigate to. Your consent route fetches the pending request with `getOAuth2Pending(oauthReq)` to display the client name and requested scopes, then submits the user's decision with `decideOAuth2(oauthReq, approve)`. Prelude returns the client `redirect_uri` — with a one-time `code` and `state` on approval, or `error=access_denied` on denial. The browser lands back on the client app's `redirect_uri`. The client calls `handleCallback()`, which verifies `state`, exchanges the `code` at `/v1/session/oauth/token` with its PKCE `code_verifier`, and persists the tokens so a `PrldSessionClient` on the same domain picks up the session. The issued session is a **child** of the user's browser session. Its scopes are the intersection of the requested scopes and the parent session's scopes (or the parent's full set when the client requests the `prld:oauth:inherit` scope). A session minted through the OAuth flow cannot itself start another OAuth flow — the chain is capped at one level. ## Prerequisites Before you start, make sure you have: * A Prelude account with access to Prelude Auth * An **Application ID** (`appID`) — see [Applications](/auth/documentation/applications) * Your **Management API key** for backend calls * A verified [custom domain](/auth/documentation/domain-names), or your default `${APP_ID}.session.prelude.dev` host — the authorization-server endpoints are anchored to this **auth domain** * A login UI built with the [Web SDK](/auth/documentation/frontend-sdks/web/introduction) that can host the sign-in and consent screens ## Configure Prelude as your authorization server Enable the OAuth server on your app and point it at your login UI with a single `PUT`. `default_provider_url` is the origin Prelude sends the browser to: it `302`s to `/sign-in` from `/authorize`, and to `/oauth/consent` after login. ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/oauth \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "default_provider_url": "https://auth.example.com", "registration": { "redirect_uri_allowlist": [ "https://app.example.com/callback", "http://localhost/*" ] } }' ``` For first-party SSO you typically know your client apps ahead of time, so register each one explicitly instead of enabling Dynamic Client Registration. This returns the `client_id` (an `oac_…` identifier) the client app needs: ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/oauth/clients \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_name": "Example App", "redirect_uris": ["https://app.example.com/callback"], "scopes": ["openid"], "provider_url": "https://auth.example.com" }' ``` The `client_name` you set here is what the consent screen shows the user. See the [OAuth Server Config API](/auth/api-reference/management/config/oauth-server/update-oauth-server-config) reference for the full configuration schema — including DCR and CIMD if you want clients to self-register, as covered in the [MCP Login](/auth/documentation/integration-guide/mcp) guide. ## Build the authorization frontend Prelude handles the OAuth protocol but hands the browser to **your** login UI to authenticate the user and collect consent. Build two routes at the origin you set as `default_provider_url`. Both use a standard [Web SDK](/auth/documentation/frontend-sdks/web/introduction) session client: ```javascript theme={null} import { PrldSessionClient } from "@prelude.so/js-sdk"; const client = new PrldSessionClient({ domain: "{app_id}.session.prelude.dev", }); ``` Prelude redirects here with an `?oauth_req=` parameter. Authenticate the user with any method your app supports, then resume the flow with `continueOAuth2`. Prelude returns the consent URL to navigate to. ```javascript theme={null} const params = new URLSearchParams(window.location.search); const oauthReq = params.get("oauth_req"); // ...sign the user in (OTP, password, passkey, social, SAML)... if (oauthReq) { // Resume the OAuth flow: attach the now-authenticated user to the request. const { redirectUrl } = await client.continueOAuth2(oauthReq); window.location.assign(redirectUrl); // -> your /oauth/consent route } ``` If the user already has a live session when they land here, you can skip straight to `continueOAuth2`. Prelude redirects here with the same `?oauth_req=`. This route requires a signed-in session — if there is none, send the user to `/sign-in?oauth_req=` first, preserving the parameter. Fetch the pending request to render consent, then submit the decision. ```javascript theme={null} const params = new URLSearchParams(window.location.search); const oauthReq = params.get("oauth_req"); // What the consent screen should display. const pending = await client.getOAuth2Pending(oauthReq); // pending = { clientId, clientName, redirectUri, scopes } // ...render "clientName wants access" + the scopes, with Allow / Deny buttons... const approve = true; // false when the user denies const { redirectUrl } = await client.decideOAuth2(oauthReq, approve); // Full-page navigation: redirectUrl points at the client app, carrying // ?code=&state= on approval (or ?error=access_denied on denial). window.location.assign(redirectUrl); ``` The `continue`, `pending`, and `decision` calls run against the user's live session and are bound to it with [DPoP](https://datatracker.ietf.org/doc/html/rfc9449). The Web SDK manages the session and the DPoP proof for you — you do not construct these requests by hand. ## Build the client app The application signing users in acts as a public OAuth client. The Web SDK ships `PrldOAuth2Client` for exactly this: `initiate()` builds the authorize URL (generating and storing the PKCE `code_verifier` and CSRF `state`), and `handleCallback()` validates the returned `state`, exchanges the `code`, and persists the session. ```javascript theme={null} import { PrldOAuth2Client } from "@prelude.so/js-sdk"; const oauth = new PrldOAuth2Client({ domain: "{app_id}.session.prelude.dev", clientId: "oac_01jqebhswje1ka1z7ahr9rfsgt", // from the client you registered redirectURI: "https://app.example.com/callback", scopes: ["openid"], }); ``` Call `initiate()` and send the browser to the returned `authorizationURL`. The PKCE verifier and `state` are persisted for you; `initiate()` does not navigate, so you do it. ```javascript theme={null} const { authorizationURL } = await oauth.initiate(); window.location.href = authorizationURL; ``` `initiate()` also accepts per-call `{ scopes, state }` if you want to override the constructor defaults or supply your own `state`. At your `redirectURI`, pass the query parameters to `handleCallback()`. It throws `PrldErrors.OAuth2` on a provider error, a `state` mismatch, or a failed exchange. ```javascript theme={null} import { PrldSessionClient, PrldErrors } from "@prelude.so/js-sdk"; const params = new URLSearchParams(window.location.search); try { await oauth.handleCallback({ code: params.get("code") ?? undefined, state: params.get("state") ?? undefined, error: params.get("error") ?? undefined, error_description: params.get("error_description") ?? undefined, }); // Tokens are persisted keyed by domain, just like a session login — so a // session client on the same domain now sees the authenticated user. const client = new PrldSessionClient({ domain: "{app_id}.session.prelude.dev" }); const { user } = await client.refresh(); } catch (err) { if (err instanceof PrldErrors.OAuth2) { // err.code is the OAuth error, e.g. access_denied or invalid_grant console.error(err.code); } } ``` `handleCallback()` returns the raw token result (`{ accessToken, tokenType, expiresIn, refreshToken?, scope? }`) if you want to inspect it, but you rarely need to — it persists the session for you, so continue with a `PrldSessionClient` as you would after any other login. ## What's next? Build the sign-in and consent screens your login UI hosts. The same authorization server, for MCP clients — including DCR and CIMD self-registration. The raw authorize, token, continue, pending, and decision endpoints. Control which scopes a session — and its OAuth children — can carry. # Introduction to Auth Source: https://docs.prelude.so/auth/documentation/introduction Manage the full lifecycle of your users' authentication. ## Overview Prelude Auth is a complete authentication API for developer teams. With this API, you can manage users, handle authentication across multiple methods, track active sessions, and maintain user profiles, all through a simple and secure REST interface. To enable Prelude Auth on your account, [contact us](mailto:support@prelude.so). ## Key Features * **OTP Login**: Authenticate users via SMS or email one-time password * **Password Authentication**: Email and password login with configurable rules * **Social Login** OAuth with Google, Apple, Microsoft, GitHub, and Okta * **Step-Up Authentication** Add scoped, multi-step challenges for sensitive operations * **Server Tokens**: Server-to-server authentication using OAuth 2.0 client credentials * **Session Tracking**: Monitor active sessions with creation and last-seen timestamps * **User Management** Create, retrieve, update, and delete users with unique identifiers * **Identity Verification**: Associate email addresses and phone numbers with user accounts * **Profile Management**: Store and update user profile information * **Webhook Integration**: Configure event notifications for real-time updates Prelude Auth helps you build robust authentication systems while offloading the complexity of session management, allowing you to focus on creating great user experiences. ### Why choose Prelude Auth? * **One platform, zero data silos** Prelude handles both verification and authentication, so user data carries over seamlessly. Stronger fraud signals, fewer false positives, and full visibility across the onboarding flow. * **Flexible step-up authentication** Trigger scoped challenges only when needed, for sensitive operations like password changes or high-risk transactions, without disrupting the user journey. * **Pay for what you use** Customers have saved more than 50% vs. their previous provider by paying only for the features they actually need. ### Get started Follow the [Integration Guide](https://docs.prelude.so/auth/documentation/integration-guide/introduction) to set up your first authentication flow in minutes. # Passkey (WebAuthn) Source: https://docs.prelude.so/auth/documentation/passkey Use a WebAuthn credential as a step-up factor — verified by Prelude, no delegation hook involved. Passkey is a managed step-up step: the WebAuthn assertion is verified by Prelude server-side against a credential the user registered earlier in their session. Unlike OTP steps which rely on a code delivered out-of-band, the passkey ceremony binds the proof to the origin (phishing-resistant) and to a private key that never leaves the user's authenticator. | Step key | Verified by | Out-of-band channel | Phishing-resistant | | ---------------- | ------------ | ------------------- | ------------------ | | `verify_sms` | Your backend | SMS | No | | `verify_email` | Your backend | Email | No | | `verify_passkey` | **Prelude** | None (WebAuthn) | **Yes** | No delegation hook is called for `verify_passkey`: the assertion is verified locally against the credential registered for the user. Sign-count monotonicity provides clone detection; the surrounding challenge token's JTI provides the anti-replay guarantee. ## Prerequisites * A Prelude account with access to the Auth API * An **Application ID** (`appID`) — see [Applications](/session/documentation/applications) * Your **Management API key** for backend calls * Step-up enabled on the application — see [Step-Up Authentication](/session/documentation/step-up-authentication) * A working login flow (users must be authenticated before they can register a passkey) * The frontend served over HTTPS, or `http://localhost:` for local development — the WebAuthn API refuses any other origin ## How it works ```mermaid theme={null} sequenceDiagram autonumber actor U as User (logged in) participant B as Browser (WebAuthn) participant SDK as Frontend SDK participant P as Prelude API rect rgba(76, 175, 80, 0.12) Note over U,P: Registration (one-time, in-session) U->>SDK: registerPasskey({ username, displayName }) SDK->>P: POST /v1/session/me/passkeys/register/begin P-->>SDK: PublicKeyCredentialCreationOptions + registration_token SDK->>B: navigator.credentials.create({ publicKey }) B-->>U: Authenticator prompt (Touch ID / YubiKey / ...) U-->>B: Approve B-->>SDK: AuthenticatorAttestationResponse SDK->>P: POST /v1/session/me/passkeys/register/finish (registration_token, attestation) P-->>P: Verify attestation, persist credential P-->>SDK: credential summary end rect rgba(33, 150, 243, 0.12) Note over U,P: Step-up (every time the scope is requested) U->>SDK: requestStepUp({ scope: "transfer:write" }) SDK->>P: POST /v1/session/stepup/request P-->>SDK: challenge_token (current_step: verify_passkey)
+ public_key_credential_request_options SDK->>B: navigator.credentials.get({ publicKey }) B-->>U: Authenticator prompt U-->>B: Approve B-->>SDK: AuthenticatorAssertionResponse SDK->>P: POST /v1/session/stepup/continue (challenge_token, passkey_assertion) P-->>P: Verify assertion, advance sign count P-->>SDK: challenge_token (current_step: completed) SDK->>P: POST /v1/session/refresh P-->>SDK: access_token with granted scope end ``` Registration is a separate, in-session flow; the credentials it produces are reusable across as many step-up scopes as you configure with a `verify_passkey` step. ## Configure passkeys on the app The Relying Party (RP) identity is shared across every passkey ceremony on the application. Changing it after credentials are registered invalidates them at the authenticator layer, so set it once per environment. ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/passkey \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "rp_id": "example.com", "rp_name": "Example", "allowed_origins": ["https://example.com", "https://app.example.com"], "user_verification": "required", "attestation_preference": "none", "login_enabled": false }' ``` | Field | Required | Description | | ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `rp_id` | Yes | The Relying Party identifier — the effective domain (no scheme, no port). Credentials are scoped to this RPID; changing it later breaks existing credentials. | | `rp_name` | Yes | Human-readable display name shown by authenticators during ceremonies. | | `allowed_origins` | Yes | List of permitted origins (scheme + host + optional port). Must be a superset of the RPID. For local development, `http://localhost:` is accepted. | | `user_verification` | No | `required` (default), `preferred`, or `discouraged`. Use `required` for MFA so the ceremony proves something the user knows or is, not just possession. | | `attestation_preference` | No | `none` (default), `indirect`, `direct`, or `enterprise`. `none` is privacy-preserving and sufficient for most deployments. | | `login_enabled` | No | Defaults to `false`. Set to `true` to opt the app into primary-factor (passwordless) passkey login at `/v1/session/login/passkey/{begin,finish}`. While the flag is on, every new registration also requests a discoverable credential (`residentKey: required`); authenticators that cannot store a resident key — most notably some older hardware security keys — will refuse the ceremony with `passkey_registration_failed`. The step-up / MFA path always works regardless of this flag. | | `aaguid_allowlist` | No | List of authenticator-model AAGUIDs (UUID strings) that registration is allowed to accept. Empty list disables the filter. When non-empty, registrations with a non-matching AAGUID are rejected with `passkey_authenticator_blocked`. Pairs naturally with `attestation_preference: "direct"` or `"enterprise"` — with `"none"` most authenticators return an all-zeros AAGUID and the allowlist matches nothing. | | `aaguid_blocklist` | No | List of AAGUIDs to reject outright. Evaluated alongside `aaguid_allowlist` (blocklist wins). Typical use: blocklist `00000000-0000-0000-0000-000000000000` to refuse unattested credentials, or pin a specific known-bad authenticator out of an otherwise broad allow policy. | Enterprise example — accept only two authenticator models and explicitly reject unattested credentials: ```bash theme={null} curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/passkey \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "rp_id": "example.com", "rp_name": "Example", "allowed_origins": ["https://example.com"], "user_verification": "required", "attestation_preference": "direct", "aaguid_allowlist": [ "ee882879-721c-4913-9775-3dfcce97072a", "08987058-cadc-4b81-b6e1-30de50dcbe96" ], "aaguid_blocklist": [ "00000000-0000-0000-0000-000000000000" ] }' ``` The blocklist is evaluated first; an AAGUID that's on it is rejected even when it also appears in the allowlist. Filters only run at **registration** time — credentials that were stored before the policy was set or changed remain valid for assertions, so tightening the policy doesn't retroactively lock anyone out. To enforce the new policy on the existing credential set, use the `DELETE /me/passkeys/{credentialID}` and re-registration flows. AAGUID lookup tip: the FIDO Alliance publishes a Metadata Service (MDS) mapping AAGUID → authenticator vendor / model. The values we accept here are the same canonical UUID strings the MDS uses, so a dashboard can resolve them to human-readable names on render. Add the step key to your step-up configuration and reference it from any scope whose challenge should require a passkey. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "step_keys": [ { "key": "verify_passkey", "description": "WebAuthn second factor" } ], "allowed_scopes": [ { "scope": "transfer:write", "mode": "direct", "direct": { "identifier_types": ["email_address", "phone_number"], "status": "review", "grant_mode": "single-use", "granted_for": 600, "steps": [ { "order": 1, "key": "verify_passkey", "expiration_duration": 60 } ] } } ] }' ``` A scope may combine `verify_passkey` with other steps (OTP, custom). When `verify_passkey` is the current step, Prelude verifies locally; for any other step the regular dispatch applies. ## Fall back to OTP for users without a passkey A registered passkey shows up on the user as a regular identifier of type `passkey`, alongside the user's email and phone identifiers. Direct-mode entries select on it via `identifier_types`, the same way they select on `email_address` or `phone_number`. List two entries on the same scope — the passkey-gated one first, the OTP fallback second — and the runtime serves the first one whose identifier types the user holds: ```json theme={null} "allowed_scopes": [ { "scope": "transfer:write", "mode": "direct", "direct": { "identifier_types": ["passkey"], "status": "review", "grant_mode": "single-use", "granted_for": 600, "steps": [{ "order": 1, "key": "verify_passkey", "expiration_duration": 60 }] } }, { "scope": "transfer:write", "mode": "direct", "direct": { "identifier_types": ["email_address", "phone_number"], "status": "review", "grant_mode": "single-use", "granted_for": 600, "steps": [{ "order": 1, "key": "verify_sms", "expiration_duration": 300 }] } } ] ``` A user with a registered passkey matches the first entry and is routed to `verify_passkey`. A user without one falls through to the OTP entry. If neither entry's identifier types match (e.g. a user with no email/phone and no passkey), the request returns `passkey_step_unavailable`. For customers running their own step-up backend, the delegation hook payload includes a `has_passkey` boolean as a convenience — your backend can route to `verify_passkey` when it's true without scanning the `identifiers` array for a `passkey` entry: ```json theme={null} POST { "scope_requested": "transfer:write", "user_id": "usr_...", "identifiers": [...], "has_passkey": true, "signals": {...}, "metadata": {...} } ``` Your hook can then return `verify_passkey` when `has_passkey` is true, falling back to an OTP step otherwise — see the [Step-Up Hook reference](/session/documentation/step-up-hook) for the response shape. ## Register a passkey Registration runs **inside an authenticated session**, and the session must hold the `prld:passkey:write` scope — a fresh access token isn't enough. Drive the user through a step-up challenge that grants `prld:passkey:write` (typically an OTP step) just before enrolment so adding an authenticator always requires an additional ownership proof. The scope is single-use server-side and is atomically consumed when the credential is stored. The two endpoints sit under `/v1/session/me/passkeys/register/`: | Endpoint | Purpose | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `POST /me/passkeys/register/begin` | Issues the `PublicKeyCredentialCreationOptions` and a short-lived `registration_token` | | `POST /me/passkeys/register/finish` | Verifies the attestation produced by the authenticator and persists the credential (consumes `prld:passkey:write`) | A session without `prld:passkey:write` is rejected on `finish` with `403 insufficient_scope` — drive a step-up to grant the scope before retrying. The frontend SDK wraps both in a single method: ```ts theme={null} const { credential, alreadyRegistered } = await session.registerPasskey({ username: "user@example.com", // shown by the authenticator displayName: "User", // optional, defaults to username nickname: "MacBook" // optional, internal label }); if (alreadyRegistered) { // This credential was already stored for the user — server returned a no-op success. } ``` The `username` is the WebAuthn `user.name` (typically the user's email or phone). The `nickname` is server-side-only — useful to let users tell their credentials apart in a "Manage your passkeys" UI. After a successful enrolment the SDK invalidates its cached session and refreshes it, so the next access token reflects the consumed scope and the (optionally mapped) `has_passkey` claim. A user may register **more than one credential** (a platform passkey on their phone plus a hardware security key, for instance). The registration ceremony pre-populates `excludeCredentials` from the user's existing set, so an authenticator that already holds a credential is asked not to create a duplicate. When the authenticator honors this, the browser aborts the ceremony with `InvalidStateError`, which the SDK surfaces as a thrown `PasskeyRegistrationFailedError` — not a success. The `alreadyRegistered: true` result is a separate, server-side path: if an attestation for an already-stored credential id does reach `finish`, Prelude returns the existing credential as a no-op success instead of overwriting it. Treat `alreadyRegistered` as the idempotent re-run signal, and catch `PasskeyRegistrationFailedError` for the duplicate-authenticator case. ### Surfacing passkey state to your frontend If you'd rather let the frontend decide whether to prompt the user to register a passkey, expose the flag in the access token via [custom claims](/session/documentation/custom-claims) by mapping the built-in `has_passkey` input: ```json theme={null} { "mapping": { "has_passkey": { "$input": "has_passkey", "$type": "bool" } } } ``` The flag is recomputed on every access token issuance and flips on the active session's next refresh as soon as a credential is registered or removed — no extra round-trip needed. ## Manage registered passkeys Three endpoints sit under `/v1/session/me/passkeys/` for the "Manage your passkeys" UI users build on top of the SDK: | Endpoint | Purpose | Scope | | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | | `GET /me/passkeys` | List every credential the user has registered, with nickname, transports, backup state, `created_at`, and `last_used_at`. Empty list (not 404) when the user has none. | Authenticated session | | `PATCH /me/passkeys/{credentialID}` | Rename a credential. Empty `nickname` clears the label. Cosmetic — no scope. | Authenticated session | | `DELETE /me/passkeys/{credentialID}` | Remove a credential. The authenticated session is authoritative for managing its owner's credentials — no scope required. | Authenticated session | The SDK exposes these as `session.listPasskeys()`, `session.renamePasskey(credentialID, nickname)`, and `session.deletePasskey(credentialID)`. `deletePasskey` ends with an `invalidateCache()` + `refresh()` since removing a credential can flip the `has_passkey` claim. ```ts theme={null} // "Manage your passkeys" — render the list. const passkeys = await session.listPasskeys(); // Rename — no scope required. await session.renamePasskey(credential.credential_id, "iPad"); // Delete — no scope required either. await session.deletePasskey(credential.credential_id); ``` ### Compromise response — wipe all passkeys on session revoke `POST /v1/session/me/revoke?target=all` is the user-facing "sign me out everywhere" action and the typical compromise-response entry point. When that target is invoked, every passkey credential the user holds is wiped alongside the session revoke — the credential rows go, the `passkey` identifier rows go, and a `user.passkey.deleted` event fires per wiped credential. The next sign-in starts from a clean slate and the user re-enrols any device they still trust. Routine cleanup (one stale device, no compromise) goes through `DELETE /me/passkeys/{credentialID}` instead — that path leaves other credentials and other sessions untouched. The wipe-on-revoke behaviour is intentionally scoped to `target=all`: `target=others` and `target=mine` leave passkeys in place because they aren't compromise responses (you're keeping the current device). Admin-driven account deletion (`DELETE /v2/session/apps/{appID}/users/{userID}`) already cascades everything — sessions, identifiers, and the credential rows attached to them — through the user-deletion transaction, so no separate passkey step is needed there. ## Use the passkey in step-up Once a credential is registered, calling `requestStepUp` for any scope whose first step is `verify_passkey` returns the WebAuthn assertion options alongside the challenge token. The SDK caches the options under the challenge id, so completing the ceremony is parameter-light: ```ts theme={null} let challengeId: string | undefined; await session.requestStepUp({ scope: "transfer:write", onChallenge: (info) => { challengeId = info.challengeId; if (info.currentStep !== "verify_passkey") { // Fall back to OTP / custom step UI. return; } } }); if (challengeId) { await session.continueWithPasskey({ challengeId }); // session is refreshed; the access token now carries "transfer:write" } ``` `continueWithPasskey` runs `navigator.credentials.get()` against the cached options, posts the assertion to `POST /v1/session/stepup/continue`, and lets the SDK refresh the session as usual. The scope is granted exactly when `current_step` reaches `completed`, with the lifetime / mode from the step-up configuration. For browsers without WebAuthn support, gate the UI on the `isPasskeySupported()` helper: ```ts theme={null} import { isPasskeySupported } from "@prelude.so/js-sdk"; if (!isPasskeySupported()) { // Skip passkey entirely; offer SMS / email step instead. } ``` ## Sign in with a passkey (primary factor) A registered passkey can also stand on its own as the primary login factor — no email/phone OTP, no password. The ceremony uses WebAuthn discoverable credentials: the browser is given an empty `allowCredentials` list and surfaces every passkey it holds for the configured RPID. The selected authenticator's response carries the userHandle (= the user's UUID bytes) we set at registration, so the server resolves the user without ever asking the client for an identifier. This flow is **opt-in per app**: set `login_enabled: true` on the `PasskeyConfig` first (`POST` or `PUT` `/v2/session/apps/{appID}/config/passkey`). Until that flag is on the endpoints reject every request with `passkey_not_configured`; the step-up / MFA flow keeps working regardless. **Existing credentials may not be discoverable.** Turning `login_enabled` on does not retroactively migrate credentials registered while it was off — those were created with the WebAuthn default (`residentKey: preferred`), which means platform passkeys (iCloud Keychain, Google, Microsoft, 1Password, ...) are typically discoverable but hardware security keys often are not. Affected users keep using step-up MFA without change, but to use the passwordless flow they need to register a new credential. The endpoints sit under `/v1/session/login/passkey/`: | Endpoint | Purpose | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST /login/passkey/begin` | Returns the `PublicKeyCredentialRequestOptions` (discoverable, no allowCredentials) and a short-lived `login_token`. Unauthenticated. | | `POST /login/passkey/finish` | Verifies the assertion against the cached challenge, resolves the user from the assertion's userHandle, advances the credential's sign count, and returns a `challenge_token` for `/login/finalize`. | The flow shares the same login-finalization step as the OTP / password paths — the token returned by `finish` is the standard login challenge token, redeemable for an access + refresh token via `POST /login/finalize`. Sessions minted through this path carry `login_method: "passkey"`. ```mermaid theme={null} sequenceDiagram autonumber actor U as User (not yet signed in) participant B as Browser (WebAuthn) participant SDK as Frontend SDK participant P as Prelude API SDK->>P: POST /v1/session/login/passkey/begin P-->>SDK: PublicKeyCredentialRequestOptions + login_token SDK->>B: navigator.credentials.get({ publicKey, mediation: "required" }) B-->>U: Authenticator prompt U-->>B: Approve B-->>SDK: AuthenticatorAssertionResponse (userHandle, signature) SDK->>P: POST /v1/session/login/passkey/finish (login_token, assertion) P-->>P: Verify assertion, resolve user from userHandle P-->>SDK: challenge_token (login_method: "passkey") SDK->>P: POST /v1/session/login/finalize P-->>SDK: access_token + refresh_token ``` Three notes worth flagging: * **Discoverable credentials are required.** A passkey registered through `registerPasskey` already qualifies — the authenticator stores the user handle locally on the device — so existing credentials light up the login flow without re-enrolment. * **Rate-limited.** `/login/passkey/begin` is unauthenticated, so the bucket is keyed on the app id (default: 600/min). A leaked client SDK can't spam begin ceremonies beyond the configured budget. * **Conditional UI.** Pair the call with `navigator.credentials.get({ ..., mediation: "conditional" })` (via the SDK) to power passkey autofill on the username field. The same assertion shape works for both eager and conditional flows. ## What Prelude does 1. **Begin**: generates a 32-byte challenge, stashes the ceremony state under a single-use UUID in Redis (5-minute TTL, bound to the session), and returns the UUID as the `registration_token`. The state is GET-and-DEL'd on finish so the same token cannot be replayed. When `login_enabled` is on, the creation options also request `residentKey: required` so the resulting credential is discoverable. 2. **Finish**: validates the attestation / assertion against the cached challenge, the configured RPID, and the allowed origins. Rejects ceremonies whose origin or RPID do not match, and atomically consumes the session's `prld:passkey:write` scope as part of the credential write. 3. **Persists** the credential (one row per user, per credential id) with the COSE-encoded public key, the authenticator-reported sign count, transports, AAGUID, and the backup-eligible / backup-state flags. 4. On every assertion, **advances the sign count** through a conditional update that rejects any non-monotonic value as a clone signal — surfaced as `passkey_step_unavailable` so the host app can fall back without exposing implementation details. 5. **Anti-replay** on the surrounding challenge token's JTI, exactly like the OTP steps. The challenge token cannot be reused once the assertion has succeeded. ## Constraints | Rule | Limit | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `rp_id` | Required. The effective domain; no scheme, no port. Must match every origin in `allowed_origins`. | | `allowed_origins` | At least one. Each must be a valid scheme + host (`https://...` in production; `http://localhost:` is accepted in non-prod for local development). | | `user_verification` | One of `required` / `preferred` / `discouraged`. Defaults to `required`. | | `attestation_preference` | One of `none` / `indirect` / `direct` / `enterprise`. Defaults to `none`. | | `login_enabled` | Boolean. Defaults to `false`. Required to be `true` for `/v1/session/login/passkey/{begin,finish}` to accept traffic; the step-up / MFA endpoints ignore the flag. | | Discoverable credentials | When `login_enabled` is `true`, registration requires the authenticator to create a resident (discoverable) credential. Excludes some older hardware security keys without resident-key storage. | | AAGUID allowlist / blocklist | Optional. UUID-canonical strings. When `aaguid_allowlist` is non-empty, registration is restricted to those authenticator models; `aaguid_blocklist` rejects matches outright. Only enforced at registration — existing credentials are never re-evaluated. | | Registration token TTL | 5 minutes. The same token cannot be reused after the ceremony completes. | | Step `expiration_duration` | Same step-up constraint: 0–86400 seconds, but keep it short (60 s is a generous WebAuthn timeout). | | Credentials per user | No hard limit. Encourage users to register at least two (e.g. platform passkey + hardware key) so losing one device does not lock them out. | ## Webhook events The passkey ceremonies emit three event types you can subscribe to on the app webhook configuration. The payloads mirror the `user.identifier.*` events and the dashboard's user-history feed ingests them so customer-visible audit timelines surface passkey activity. | Event | When | Payload highlights | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `user.passkey.registered` | A user finishes the WebAuthn registration ceremony. The "we added a new passkey to your account — wasn't you?" notification flow keys on this event. Idempotent re-registration does **not** re-emit. | `user_id`, `session_id`, `credential.credential_id`, `credential.nickname`, `credential.transports`, `credential.backup_state`, `registered_at` | | `user.passkey.deleted` | A user removes a credential via `DELETE /me/passkeys/{id}`. | `user_id`, `session_id`, `credential.*` (snapshot from just before the delete), `deleted_at` | | `user.passkey.assertion_failed` | An assertion fails to verify — bad signature, sign-count regression, expired login token, unknown user, etc. Useful for brute-force / cloned-authenticator detection. | `user_id` (best-effort), `session_id` (best-effort), `credential_id` (best-effort), `source` (`step_up`\|`login`), `reason` (`invalid_assertion`\|`sign_count_replay`\|`token_invalid`\|`no_credentials`), `occurred_at` | The `reason` field on `user.passkey.assertion_failed` is the typed `PasskeyAssertionFailureReason` so subscribers can branch without parsing free-form messages. `credential_id` is empty on early failures (e.g. unknown login token) and present once the WebAuthn library has resolved the credential. ## Errors | Status | Code | Cause | | ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 400 | `bad_request` | Missing or malformed registration body. | | 400 | `passkey_registration_failed` | The attestation could not be verified — typically a bad challenge, mismatched origin, or excluded credential. | | 400 | `passkey_authenticator_blocked` | Registration refused because the credential's AAGUID is missing from `aaguid_allowlist` or present in `aaguid_blocklist`. Route the user to a different authenticator (or relax the policy). | | 400 | `passkey_step_unavailable` | The user has no registered credentials, the assertion failed verification (including sign-count regression — possible cloned authenticator), or the request supplied no assertion payload. | | 401 | `unauthorized` | `/login/passkey/finish` — the login token is unknown / expired, or the assertion failed verification. Indistinguishable from "no matching credential" by design so an attacker cannot probe for valid users. | | 403 | `insufficient_scope` | The session does not hold `prld:passkey:write`. Run a step-up to grant the scope before retrying registration. | | 404 | `not_found` | `GET`, `PATCH`, or `DELETE` `/me/passkeys/{credentialID}` — no credential matches the (user, credentialID) pair. | | 429 | `rate_limited` | `/passkey/register/begin` or `/login/passkey/begin` — too many ceremonies launched in the rate-limit window. Honor `Retry-After` and back off. | | 403 | `passkey_not_configured` | The application has no `PasskeyConfig`. Configure the Relying Party first. | When you receive `passkey_step_unavailable`, route the user to a fallback step (SMS OTP, email OTP, custom step). The SDK exposes a dedicated `PasskeyStepUnavailableError` and a `PasskeyNotSupportedError` for browsers without WebAuthn. ## Security recommendations **Use `user_verification: "required"` for MFA.** Without it, the ceremony only proves possession of the authenticator, not that the user was present and verified — which collapses passkey to a "something you have" factor instead of "something you have + something you are/know". * **Encourage backup credentials.** A user with a single device-bound credential (hardware key, non-syncing platform passkey) cannot recover if they lose that device. Let them register more than one in your account-management UI. * **Treat sign-count regressions as security incidents.** Prelude rejects them automatically, but if your fraud telemetry shows a credential repeatedly hitting `passkey_step_unavailable` after previously working, investigate. * **Rotate the Relying Party with care.** Changing `rp_id` invalidates every existing credential. If you must, communicate it to users in advance and let them re-register before deprecating the old RPID. ## What's next? Full step-up overview, including custom scopes and the delegation hook. Add client-owned steps (KYC, biometric, ...) to your challenges. Full code path for the JS SDK, including the passkey methods. # Register an Identifier Source: https://docs.prelude.so/auth/documentation/register-identifier Add a phone number or email address to a logged-in user with an OTP challenge. Once a user is authenticated, you may want to let them attach a new identifier to their account: add a recovery phone number, switch to a new email address, enable a second login channel. Prelude exposes two **reserved scopes** that drive this flow end-to-end: | Scope | Identifier added | OTP step | | --------------------- | ---------------- | -------------- | | `prld:phone:register` | `phone_number` | `verify_sms` | | `prld:email:register` | `email_address` | `verify_email` | Register-identifier scopes are **preformatted**: Prelude runs the OTP challenge itself and does not call your delegation hook. You still need to declare the scope in your step-up configuration's `allowed_scopes` with `mode: "managed"` so the frontend is permitted to request it. The client passes the identifier value on the request, Prelude validates it, runs the OTP challenge, and persists the identifier on the user's profile when the challenge completes. ## How it works ```mermaid theme={null} sequenceDiagram autonumber actor U as User (logged in) participant SDK as Frontend SDK participant P as Prelude API U->>SDK: Submit new phone / email SDK->>P: POST /v1/session/stepup/request
(scope=prld:phone:register, metadata.identifier=+1555…) P-->>P: Validate value, check uniqueness P-->>SDK: status=review, steps=[verify_sms] loop Managed OTP step U->>SDK: Enter OTP code SDK->>P: startOTP / checkOTP (challengeId) P-->>SDK: step done end P-->>P: Attach identifier to user P-->>SDK: step_up_token (challenge complete) P->>P: Emit user.identifier.created event ``` The new identifier is **only attached to the user when the OTP step succeeds**. If the user abandons the challenge or fails to enter a valid code, no change is made to their profile. ## Prerequisites * A Prelude account with access to the Session API * An **Application ID** (`appID`) — see [Applications](/session/documentation/applications) * Step-up enabled on the application — see [Step-Up Authentication](/session/documentation/step-up-authentication). Register scopes reuse the step-up signing keys and JWKS, so step-up must be configured on the app * The register scope you want to expose declared in `allowed_scopes` on the step-up configuration with `mode: "managed"` (`prld:phone:register` and/or `prld:email:register`). The OTP step and the identifier write are handled by Prelude — no delegation hook is called for these entries * An OTP login configuration matching the identifier type you want to register (an SMS configuration for `prld:phone:register`, an email configuration for `prld:email:register`) Register scopes must appear in `allowed_scopes` on the step-up configuration with `mode: "managed"`. A request for a scope that is not allowed is rejected with `scope_not_allowed` before any OTP is sent. The `managed` mode is reserved for these two scopes and rejects any other scope at configuration time. ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "step_keys": [], "allowed_scopes": [ { "scope": "prld:phone:register", "mode": "managed" }, { "scope": "prld:email:register", "mode": "managed" } ] }' ``` ## Trigger the flow The frontend SDK initiates the flow with a regular `requestStepUp` call. The new identifier is passed on the request `metadata` under the key `identifier`: ```ts theme={null} // Add a phone number await session.requestStepUp({ scope: 'prld:phone:register', metadata: { identifier: '+15551234567' } }); // Add an email address await session.requestStepUp({ scope: 'prld:email:register', metadata: { identifier: 'user@example.com' } }); ``` Prelude returns `status=review` with a single OTP step keyed `verify_sms` or `verify_email`. From there the SDK drives the OTP step exactly like any other step-up challenge — see the [Web SDK Step-Up guide](/session/documentation/frontend-sdks/web/step-up) for the full code path. ## What Prelude does 1. **Parse** the value carried under `metadata.identifier`. Phone numbers are normalized to E.164; emails are lowercased and normalized. 2. **Reject malformed values** with a `bad_request` error before any OTP is sent. 3. **Check uniqueness**: if the identifier is already attached to a user (the requesting user or anyone else), the request is rejected with `identifier_already_exists` (HTTP 409). The OTP is never sent in this case. 4. **Synthesize a single-use challenge** with one OTP step. The grant lifetime and the step expiration are both 10 minutes (`granted_for: 600`). 5. **Embed the canonical identifier** on the challenge token so the value cannot be substituted by the client between `stepup/request` and the OTP completion. 6. On OTP success, **attach the identifier** to the user via the same path as the [Add Identifier management endpoint](/session/api-reference/management/identifiers/create-identifier). A `user.identifier.created` webhook event is emitted. If a competing flow registers the same identifier between `stepup/request` and the OTP completion, the OTP check fails with `identifier_already_exists` and the identifier is not attached. ## Constraints | Rule | Limit | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `metadata.identifier` | Required. Phone numbers must be valid E.164. Email addresses must be syntactically valid. Maximum 320 characters (other step-up metadata values are capped at 32). | | Grant lifetime | Fixed at 10 minutes. The grant uses `grant_mode: "single-use"` and is consumed by the identifier write itself; the client should not reuse the resulting access token for anything else. | | Identifier ownership | The identifier must not already be attached to any user on the application. Use [Delete Identifier](/session/api-reference/management/identifiers/delete-identifier) first if you want to move an identifier between users. | | OTP login configuration | An OTP configuration matching the identifier type must exist on the app. Without it, the OTP step cannot be sent. | ## Errors | Status | Code | Cause | | ------ | --------------------------- | ------------------------------------------------------------------------------------------------------ | | 400 | `bad_request` | `metadata.identifier` is missing, malformed, or exceeds the metadata value limit. | | 409 | `identifier_already_exists` | The value is already attached to a user — either at request time, or as a race when the OTP completes. | All other [step-up errors](/session/api-reference/frontend/stepup-request) (expired challenge, invalid token, replay, etc.) apply unchanged. ## What's next? Full step-up overview, including custom scopes and the delegation hook. Companion preformatted flow for the `prld:pwd:write` scope. # Server Tokens Source: https://docs.prelude.so/auth/documentation/server-tokens Authenticate server-to-server requests using OAuth client credentials. This guide walks you through obtaining access tokens for server-to-server authentication using the OAuth 2.0 client credentials flow. This is useful when your backend application needs to call other services or APIs on behalf of itself, without involving a user. ## Prerequisites Before you start, make sure you have: * A Prelude account with access to Prelude Auth * An **Application ID** (`appID`) — see [Applications](/auth/documentation/applications) * Your **Management API key** for backend calls ## How it works The client credentials flow allows your backend application to: 1. Register an OAuth client (a "service account") with Prelude Auth 2. Exchange the client credentials (ID and secret) for an access token 3. Use the token to authenticate requests to your backend or other services This flow is ideal for: * Service-to-service communication * Scheduled jobs and background tasks * Microservices that need to call each other * API integrations that don't involve a user ## Set up server tokens Create a new OAuth client registered for the `client_credentials` grant type: ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/oauth/clients \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "client_name": "backend-service", "redirect_uris": [], "provider_url": "https://api.example.com", "scopes": ["read", "write"], "grant_types": ["client_credentials"] }' ``` The response includes your `client_id` and `client_secret`. Store the secret securely — you'll need it to request tokens. **The secret is only returned once** — if you lose it, you'll need to rotate the client. | Field | Required | Description | | --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- | | `client_name` | Yes | A human-readable name for this client (e.g., `"backend-service"`, `"background-jobs"`). | | `provider_url` | Yes | The URL where your application operates. Used for security validation. | | `redirect_uris` | No | Can be an empty array for client credentials clients. | | `scopes` | No | Scopes this client is allowed to request (e.g., `["read", "write"]`). Tokens can only request scopes registered for the client. | | `grant_types` | Yes | For server tokens, use `["client_credentials"]`. | Exchange your client credentials for an access token: ```bash theme={null} curl -X POST https://api.prelude.dev/v1/session/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d 'grant_type=client_credentials' \ -d 'client_id=${CLIENT_ID}' \ -d 'client_secret=${CLIENT_SECRET}' \ -d 'scope=read write' ``` Alternatively, use HTTP Basic authentication: ```bash theme={null} curl -X POST https://api.prelude.dev/v1/session/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Authorization: Basic $(echo -n '${CLIENT_ID}:${CLIENT_SECRET}' | base64)" \ -d 'grant_type=client_credentials' \ -d 'scope=read write' ``` The response contains: ```json theme={null} { "access_token": "eyJhbGc...", "token_type": "Bearer", "expires_in": 3600, "scope": "read write" } ``` | Parameter | Description | | --------------- | --------------------------------------------------------------------------------------------------------------------------- | | `grant_type` | Must be `"client_credentials"`. | | `client_id` | Your registered client ID. | | `client_secret` | Your registered client secret. Keep this secure. | | `scope` | (Optional) Scopes to request. Must be a subset of the scopes registered for your client. If omitted, no scopes are granted. | Note: Client credentials tokens do not include a `refresh_token`. When the token expires, request a new one. Include the token in the `Authorization` header of your requests: ```bash theme={null} curl https://your-backend.example.com/api/resource \ -H "Authorization: Bearer ${ACCESS_TOKEN}" ``` On your backend, verify the token signature using the [JWKS endpoint](/auth/documentation/jwks). The token's subject (`sub` claim) will be the client ID. ## Token claims Tokens issued via client credentials include the following claims: ```json theme={null} { "sub": "client_id_...", "scope": "read write", "app_id": "app_...", "iat": 1234567890, "exp": 1234571490, "iss": "https://auth.prelude.dev", "aud": "your-app-id" } ``` Key differences from user tokens: * `sub` is the client ID (not a user ID) * `session_id` is absent — there is no session * Tokens are not revocable via session invalidation * Tokens expire after 1 hour (configurable) ## Client rotation If you suspect your client secret has been compromised: 1. Create a new client with the same scopes and grant types 2. Update your application to use the new credentials 3. Delete the compromised client using the Management API ```bash theme={null} curl -X DELETE https://api.prelude.dev/v2/session/apps/${APP_ID}/oauth/clients/${CLIENT_ID} \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" ``` ## What's next? * Review the [token verification](/auth/documentation/jwks) guide to validate tokens on your backend * Set up [webhooks](/auth/documentation/webhooks/introduction) if you need to be notified of token-related events * Explore the [Management API](/auth/api-reference/management/users/list-users) for advanced token management # Step-Up Authentication Source: https://docs.prelude.so/auth/documentation/step-up-authentication Add scoped, multi-step authentication challenges to existing sessions. Step-up authentication lets you require additional proof from an already-authenticated user before granting access to sensitive operations. Instead of re-authenticating from scratch, the user completes a challenge (SMS OTP, email OTP, etc.) and receives a time-limited scope on their session. ## Prerequisites * A Prelude account with access to the Auth API * An **Application ID** (`appID`) — see [Applications](/session/documentation/applications) * Your **Management API key** for backend calls * A working login flow (users must be authenticated before requesting step-up) ## How it works When a user requests a sensitive action, your frontend asks Prelude for a scope grant. Prelude calls a **hook** on your backend, where you decide whether to grant the scope immediately, require a challenge, or block the request entirely. If you require a challenge, Prelude walks the user through each step you defined. Prelude natively handles **managed steps** like `verify_sms` and `verify_email` — no extra work on your side. Once every step is completed, the scope is granted to the user's access token. ```mermaid theme={null} sequenceDiagram autonumber actor U as User participant P as Prelude API participant B as Your Backend U->>P: POST /v1/session/stepup/request (scope) P->>B: Hook (scope_requested, identifiers, signals, metadata) B-->>P: status, granted_for, grant_mode, steps[] alt status = continue P-->>U: Scope granted immediately else status = review P-->>U: challenge_token (steps to complete) loop For each managed step U->>P: Complete OTP flow with challenge_token P-->>U: Updated challenge_token end U->>P: POST /v1/session/refresh (step_up_token) P-->>U: access_token with granted scope else status = block P-->>U: Scope denied end ``` You can also define **custom steps** handled by your own backend (e.g. KYC review, biometric check). See [Custom Steps](/session/documentation/step-up-custom-steps) for details. ## Configure step-up Register each scope you want to expose and how its decision is produced. Use `mode: "delegated"` to call your delegation hook, `mode: "direct"` to serve a static decision without any hook call, or `mode: "managed"` to route to a preformatted Prelude-driven flow (reserved for `prld:phone:register` and `prld:email:register`). ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "jwks_url": "https://api.example.com/.well-known/jwks.json", "step_keys": [], "allowed_scopes": [ { "scope": "transfer:write", "mode": "delegated", "delegated": { "delegation_hook": "https://api.example.com/hooks/stepup" } }, { "scope": "payment:confirm", "mode": "delegated", "delegated": { "delegation_hook": "https://api.example.com/hooks/stepup" } } ] }' ``` | Field | Required | Description | | -------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `jwks_url` | When any scope uses `delegated` mode | Your JWKS endpoint. Used to verify verification tokens issued by your backend for [custom steps](/session/documentation/step-up-custom-steps). | | `step_keys` | Yes | Custom step keys for client-owned steps. Leave empty if you only use managed steps. | | `allowed_scopes[].scope` | Yes | The scope name. | | `allowed_scopes[].mode` | Yes | `delegated` to call your delegation hook, `direct` to serve a static decision, or `managed` to route to a preformatted Prelude-driven flow (`prld:phone:register` / `prld:email:register`). | | `allowed_scopes[].delegated.delegation_hook` | When `mode` is `delegated` | The URL Prelude calls when this scope is requested. See [Step-Up Hook](/session/documentation/step-up-hook). | | `allowed_scopes[].direct` | When `mode` is `direct` | The static decision to return. See [Change Password](/session/documentation/change-password) for an end-to-end direct example. | Scope names must only contain: lowercase letters, uppercase letters, numbers, and the characters `.-_:`. A scope may appear more than once in `allowed_scopes`. Multiple `direct` entries are allowed as long as each `(scope, identifier_type)` pair is unique — useful when the step differs per identifier type. At most one `delegated` entry per scope is allowed; when combined with `direct` entries, it is used as a fallback if no `direct` entry matches the user's identifier types at runtime. At most one `managed` entry per scope is allowed, and only for the preformatted scopes `prld:phone:register` and `prld:email:register`. Your backend must expose the hook URL you registered. When Prelude receives a step-up request, it calls your hook with user and session context. Your hook decides whether to grant, challenge, or block. See the full [Step-Up Hook Reference](/session/documentation/step-up-hook) for the request/response format and constraints. ## The step-up flow ### 1. Request a scope grant The frontend SDK initiates the flow by calling `requestStepUp` with a scope. Prelude calls your [hook](/session/documentation/step-up-hook) and returns one of: | Status | Meaning | | ---------- | -------------------------------------------------------------------------------------------- | | `continue` | Scope granted immediately, no challenge needed. The SDK refreshes the session automatically. | | `review` | A challenge was created. The user must complete all steps. | | `block` | Scope denied. | ### 2. Complete managed steps Prelude handles the following step types natively: | Step key | Description | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `verify_sms` | SMS OTP verification sent to the user's phone number. | | `verify_email` | Email OTP verification sent to the user's email address. | | `verify_passkey` | WebAuthn assertion against a credential the user registered earlier. Verified by Prelude server-side; no delegation hook is called. See [Passkey](/session/documentation/passkey). | The SDK provides `startOTP`, `checkOTP`, and `retryOTP` methods to drive OTP steps, and `continueWithPasskey` for passkey steps — using the `challengeId` from the `onChallenge` callback. ### 3. Automatic completion When the last step is completed, the SDK automatically refreshes the session with the granted scope. The new access token behavior depends on the `grant_mode` set by your hook: | Grant mode | Behavior | | --------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `single-use` | The scope is attached only to this access token and expires after `granted_for` seconds. It is not persisted on the session. | | `session-bound` | The scope is stored on the session and included in every subsequent refresh for `granted_for` seconds. | See the [Web SDK Step-Up guide](/session/documentation/frontend-sdks/web/step-up) for the full integration with code examples. ## Constraints and validation ### Field format All external fields (scopes, step keys, metadata keys) must match: ``` a-z A-Z 0-9 . - _ : ``` No other characters are allowed. ### Metadata | Rule | Limit | | ------------------------ | ------------- | | Maximum number of fields | 5 | | Maximum key length | 12 characters | | Maximum value length | 32 characters | ### Hook response | Rule | Limit | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Maximum response size | 64 KB | | `granted_for` | In seconds. Must be between 0 and 86400 (24 hours). Cannot be negative. | | `granted_for` default | When `grant_mode` is `session-bound` and `granted_for` \< 1, it defaults to 600 seconds (10 minutes). When `grant_mode` is `single-use`, `granted_for` must be at least 1. | | Hook timeout | **5 seconds**. Your endpoint must respond within this time. | | Step `expiration_duration` | In seconds. Must be between 0 and 86400 (24 hours). Cannot be negative. | ### Step-Up JWKS Prelude exposes a dedicated JWKS endpoint for step-up token verification at: ``` https://.session.prelude.dev/.well-known/step-up-jwks.json ``` This is separate from the main [JWKS endpoint](/session/documentation/jwks) used for access token verification. ## Security recommendations **Anti-replay**: When your backend receives a request carrying a scope granted via step-up, you should allow each scoped token to be used **only once**. Track the token's `jti` claim and reject any replayed token. This prevents an attacker from reusing a captured token to repeat a sensitive action. * **Limit scope lifetime**: Use the `granted_for` field to keep scoped access as short-lived as practical. A transfer confirmation might only need 60 seconds. * **Use `grant_mode: "single-use"`** for high-sensitivity operations (e.g. fund transfers). This ensures the scope is attached to a single access token and not persisted on the session. * **Validate signals in your hook**: Prelude sends `user_agent`, `platform`, and `ip` in the hook request. Use these to detect suspicious context changes (e.g. a different IP than the original login). ## Preformatted flows Some scopes are handled by Prelude out of the box: you still declare them in `allowed_scopes`, but Prelude runs the challenge itself and does not call any delegation hook. Declare these entries with `mode: "managed"`. | Scope | Purpose | Mode | Guide | | --------------------------------------------- | ------------------------------------------------------- | --------- | -------------------------------------------------------------------- | | `prld:pwd:write` | Acquire the right to change the user's password | `direct` | [Change Password](/session/documentation/change-password) | | `prld:phone:register` / `prld:email:register` | Add a new identifier to the user after an OTP challenge | `managed` | [Register an Identifier](/session/documentation/register-identifier) | ## What's next? Add client-owned steps like KYC review or biometric checks to your challenges. Full API reference for the hook endpoint your backend must implement. Use the reserved `prld:phone:register` / `prld:email:register` scopes to add a new identifier to a user. Use the reserved `prld:pwd:write` scope to let users change their password from the account area. Use a WebAuthn credential as a managed step-up factor — phishing-resistant, no delegation hook. * Browse the [Step-Up API Reference](/session/api-reference/frontend/stepup-request) for full endpoint details * Configure step-up via the [Management API](/session/api-reference/management/config/stepup/create-stepup-config) # Custom Steps Source: https://docs.prelude.so/auth/documentation/step-up-custom-steps Add client-owned verification steps to step-up challenges. Beyond Prelude's managed steps (`verify_sms`, `verify_email`), you can define **custom steps** that your own backend handles — KYC review, biometric verification, document upload, or any other process. When a custom step is reached, your backend verifies the user and issues a signed token that Prelude validates to advance the challenge. Make sure you are familiar with the [Step-Up Authentication](/session/documentation/step-up-authentication) flow before reading this page. ## Prerequisites * A working [step-up configuration](/session/documentation/step-up-authentication#configure-step-up) * An RSA key pair for signing verification tokens * A public **JWKS endpoint** exposing your public keys ## Setup ### 1. Register your custom step keys Add your custom step keys and JWKS URL to the step-up configuration: ```bash theme={null} curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \ -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "jwks_url": "https://api.example.com/.well-known/jwks.json", "step_keys": [ { "key": "kyc_review", "description": "Identity verification via KYC provider" }, { "key": "biometric_check", "description": "Face recognition verification" } ], "allowed_scopes": [ { "scope": "transfer:write", "mode": "delegated", "delegated": { "delegation_hook": "https://api.example.com/hooks/stepup" } } ] }' ``` Step keys must only contain: `a-z`, `A-Z`, `0-9`, and `.-_:`. ### 2. Expose a JWKS endpoint Your `jwks_url` must serve a standard [RFC 7517](https://tools.ietf.org/html/rfc7517) JSON Web Key Set containing the RSA public keys used to verify your verification tokens. Each key must include a `kid` (key ID). ```json theme={null} { "keys": [ { "kty": "RSA", "kid": "my-key-1", "use": "sig", "alg": "RS256", "n": "0vx7agoebGc...", "e": "AQAB" } ] } ``` Prelude caches your JWKS for 10 minutes and automatically re-fetches on key-not-found to handle key rotation. ### 3. Return custom steps from your hook In your [hook response](/session/documentation/step-up-hook#hook-response), include your custom step keys alongside any managed steps: ```json theme={null} { "status": "review", "granted_for": 180, "grant_mode": "single-use", "steps": [ { "order": 1, "key": "verify_sms", "expiration_duration": 600 }, { "order": 2, "key": "kyc_review", "expiration_duration": 300 } ] } ``` Steps are completed in order. In this example, the user first completes SMS verification (handled by Prelude), then your KYC review. ## Completing a custom step When the challenge reaches a custom step, the user completes it on your side (your UI, your backend logic). Once verified, your backend issues a **verification token** and the user sends it to Prelude to advance the challenge. ### 1. Issue a verification token Your backend creates an **RS256 JWT** signed with your private key: ```json theme={null} { "sub": "usr_01kg1y07cze24ty0yw32jrwwf7", "exp": 1770885590, "nbf": 1770884990, "iat": 1770884990, "jti": "07b3bfc2-425d-4c78-af63-fd024918f0cf", "challenge_id": "cha_01kh8fh1hzeqvvfsmz7r1rn331", "key": "kyc_review", "status": "completed" } ``` #### Token fields | Field | Type | Description | | -------------- | --------- | ----------------------------------------------------------------------------------- | | `sub` | `string` | The Prelude user ID. Must match the challenge token's `sub`. | | `exp` | `integer` | Expiration time (Unix timestamp). | | `nbf` | `integer` | Not-before time (Unix timestamp). | | `iat` | `integer` | Issued-at time (Unix timestamp). | | `jti` | `string` | A unique JWT ID. **Must be unique across all tokens.** Prelude rejects reused JTIs. | | `challenge_id` | `string` | The challenge ID from the challenge token. Must match exactly. | | `key` | `string` | The step key being completed. Must match the current step in the challenge. | | `status` | `string` | Must be `"completed"`. | #### Token requirements | Rule | Detail | | ---------------- | ------------------------------------------------------------------------------------------------------- | | Algorithm | **RS256** only | | JWT header `kid` | Required — must match a key ID in your JWKS endpoint | | JTI uniqueness | Each `jti` can only be used once. Replayed tokens are rejected with `token_reused`. | | Field matching | `sub` and `challenge_id` must match the challenge token. The `key` must correspond to the current step. | #### Example (Node.js) ```javascript theme={null} const jwt = require("jsonwebtoken"); const { v4: uuidv4 } = require("uuid"); function issueVerificationToken(userId, challengeId, stepKey, privateKey, keyId) { return jwt.sign( { sub: userId, jti: uuidv4(), challenge_id: challengeId, key: stepKey, status: "completed" }, privateKey, { algorithm: "RS256", expiresIn: 300, notBefore: 0, keyid: keyId } ); } ``` ### 2. Advance the challenge Once your backend issues the verification token, the frontend SDK advances the challenge: ```javascript theme={null} await client.continueStepUp(verificationToken, (info) => { console.log(info.currentStep); // next step key, or "completed" }); ``` See the [Web SDK Step-Up guide](/session/documentation/frontend-sdks/web/step-up#complete-a-custom-step) for full details. Prelude validates the token by: 1. Fetching your public keys from your JWKS endpoint 2. Verifying the RS256 signature and expiration 3. Checking that `sub`, `challenge_id`, and `key` match the challenge token 4. Checking the `jti` has not been used before If all steps are now done, the SDK automatically refreshes the session with the granted scope. ## Validation errors | Error | Status | Description | | ---------------------------- | ------ | --------------------------------------------------------------------------------------- | | `invalid_verification_token` | 400 | The verification token is malformed, expired, or the signature is invalid. | | `token_mismatch` | 400 | `sub`, `challenge_id`, or `key` in the verification token does not match the challenge. | | `step_not_completed` | 400 | The step's status is not `"completed"`. | | `step_bypassed` | 400 | A previous step in the sequence has not been completed. | | `step_not_found` | 404 | The step key from the verification token does not exist in the challenge. | | `token_reused` | 409 | The verification token's `jti` has already been used. | # Step-Up Hook Reference Source: https://docs.prelude.so/auth/documentation/step-up-hook API reference for the step-up hook endpoint your backend must implement. When a user requests a scope grant via [`POST /v1/session/stepup/request`](/session/api-reference/frontend/stepup-request), Prelude calls your **step-up hook** — the `delegation_hook` you registered for that scope in the [step-up configuration](/session/api-reference/management/config/stepup/create-stepup-config) when its `mode` is `delegated`. Your hook decides whether to grant the scope immediately, require a multi-step challenge, or block the request. ## Hook request Prelude sends a **signed** `POST` request to your hook URL with the following JSON body: ```json theme={null} { "scope_requested": "transfer:write", "user_id": "usr_39CfbdV8AsXQwdphtbJe4yH07aF", "identifiers": [ { "type": "email_address", "value": "user@example.com" }, { "type": "phone_number", "value": "+33612345678" } ], "has_passkey": true, "signals": { "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...", "platform": "WEB", "ip": "203.0.113.42" }, "metadata": { "amount": "500", "currency": "USD" } } ``` ### Request fields | Field | Type | Description | | --------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `scope_requested` | `string` | The scope the user is requesting. | | `user_id` | `string` | The Prelude user ID. | | `identifiers` | `array` | The user's identifiers (email addresses, phone numbers). | | `identifiers[].type` | `string` | `"email_address"` or `"phone_number"`. | | `identifiers[].value` | `string` | The identifier value. | | `has_passkey` | `bool` | `true` when the user has at least one registered passkey credential on this app. Use it to route to `verify_passkey` when supported, falling back to an OTP step otherwise. | | `signals` | `object` | Contextual signals from the user's session. | | `signals.user_agent` | `string` | The user's browser or app user agent string. | | `signals.platform` | `string` | The platform (e.g. `"WEB"`, `"ANDROID"`, `"IOS"`). | | `signals.ip` | `string` | The user's IP address. | | `metadata` | `object` | Optional metadata passed by the frontend when requesting the scope. Max 5 fields, keys max 12 characters, values max 32 characters. | ### Request headers | Header | Description | | ---------------------------- | -------------------------------------------------------------------- | | `Content-Type` | `application/json` | | `User-Agent` | `Prelude-SessionStepUpHook/1.0` | | `X-Webhook-Signature` | Base64 URL-encoded RSASSA-PSS SHA-256 signature of the request body. | | `X-Webhook-Signature-Key-Id` | The ID of the signing key used to produce the signature. | ### Request signature The hook request is signed using the same mechanism as [webhooks](/session/documentation/webhooks/introduction#webhook-signature). Verify the `X-Webhook-Signature` header using the public key matching the `X-Webhook-Signature-Key-Id` from your application's [JWKS endpoint](/session/documentation/jwks). ## Hook response Your endpoint must return a JSON response with a verdict. ### Grant immediately Return `status: "continue"` to grant the scope without any challenge: ```json theme={null} { "status": "continue", "granted_for": 3600, "grant_mode": "session-bound" } ``` ### Require a challenge Return `status: "review"` with one or more steps the user must complete: ```json theme={null} { "status": "review", "granted_for": 180, "grant_mode": "single-use", "steps": [ { "order": 1, "key": "verify_sms", "expiration_duration": 600 }, { "order": 2, "key": "kyc_review", "expiration_duration": 300 } ] } ``` ### Block the request Return `status: "block"` to deny the scope entirely: ```json theme={null} { "status": "block" } ``` ### Response fields | Field | Type | Required | Description | | ----------------------------- | --------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `status` | `string` | Yes | `"continue"`, `"review"`, or `"block"`. | | `granted_for` | `integer` | Yes (when `continue` or `review`) | Duration in **seconds** for which the scope is granted. Must be between 0 and 86400 (24 hours). | | `grant_mode` | `string` | Yes (when `continue` or `review`) | `"single-use"` or `"session-bound"`. See below. | | `steps` | `array` | Yes (when `review`) | The steps the user must complete, in order. Must not be empty when status is `review`. Must not be present when status is `continue` or `block`. | | `steps[].order` | `integer` | Yes | The position of this step in the sequence (starting from 1). | | `steps[].key` | `string` | Yes | The step identifier. Use `"verify_sms"` or `"verify_email"` for Prelude-owned steps, or a custom key you registered in the step-up configuration. | | `steps[].expiration_duration` | `integer` | Yes | Time in seconds the user has to complete this step. Must be between 0 and 86400 (24 hours). | #### Grant modes | Mode | Behavior | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `single-use` | The scope is attached only to the next access token. It is not persisted on the session. `granted_for` must be at least 1 second. | | `session-bound` | The scope is stored on the session and included in every subsequent refresh for `granted_for` seconds. If `granted_for` is less than 1, it defaults to 600 seconds (10 minutes). | ### Response constraints | Rule | Limit | | ---------------------------------- | -------------------------------------------------- | | Maximum response body size | **64 KB** | | `granted_for` range | 0 to 86400 seconds (24 hours). Cannot be negative. | | `steps` with `review` | Must contain at least one step. | | `steps` with `continue` or `block` | Must not be present. | | Step `key` format | Only `a-z`, `A-Z`, `0-9`, and `.-_:` characters. | | Step `expiration_duration` range | 0 to 86400 seconds (24 hours). Cannot be negative. | ### Response HTTP status Your hook must return HTTP **200** with the JSON body. Any non-200 response or timeout (**5 seconds**) will cause the step-up request to fail. ## Monitoring hook failures When a call to your delegation hook fails — because the request could not be completed, the response had a non-2xx status code, or the body was malformed — Prelude emits a [`step_up.hook_failed`](/auth/documentation/webhooks/events/step-up-hook-failed) webhook event with a `reason` field identifying the failure category. Subscribe to this event to alert on hook outages or misconfiguration. ## Example implementation Here is a minimal Node.js example of a step-up hook: ```javascript theme={null} app.post("/hooks/stepup", (req, res) => { const { scope_requested, user_id, signals, metadata } = req.body; // Block requests from unknown IPs if (!isKnownIP(signals.ip)) { return res.json({ status: "block" }); } // High-value transfers require SMS verification + KYC if (scope_requested === "transfer:write" && parseInt(metadata?.amount) > 1000) { return res.json({ status: "review", granted_for: 120, grant_mode: "single-use", steps: [ { order: 1, key: "verify_sms", expiration_duration: 600 }, { order: 2, key: "kyc_review", expiration_duration: 300 } ] }); } // Low-risk operations: grant directly return res.json({ status: "continue", granted_for: 3600, grant_mode: "session-bound" }); }); ``` ## Custom steps and verification tokens If your hook returns custom step keys (anything other than `verify_sms` or `verify_email`), your backend must issue **verification tokens** to advance the challenge. See [Custom Steps](/session/documentation/step-up-custom-steps) for the full verification token format, requirements, and code examples. # User Created Source: https://docs.prelude.so/auth/documentation/webhooks/events/user-created The `user.created` event is triggered when a new user is created either from the Frontend API or the Management API. ## Event payload # Webhooks Source: https://docs.prelude.so/auth/documentation/webhooks/introduction Learn about the webhooks used by Prelude Auth. Prelude Auth uses webhooks to notify you when certain events occur. ## How to setup your Webhook Develop a webhook endpoint to receive event data POST requests and GET verification requests. Register the webhook endpoint by setting the URL and the events you want to subscribe to, using the [`POST /v2/session/apps/{appID}/webhooks`](/auth/api-reference/management/webhooks/create-webhook) endpoint. Activate the webhook using the [`POST /v2/session/apps/{appID}/webhooks/{webhookID}/activate`](/auth/api-reference/management/webhooks/activate-webhook) endpoint. Prelude's webhook service will send a GET activation request to the webhook endpoint. Make sure to return a `200 OK` along with the challenge response to acknowledge receipt of the event. See more details in the [Activation Challenge Request](/auth/documentation/webhooks/verification-event) page. Start receiving events. For each request to your webhook, also validate the signature. Make sure to return a `200 OK` HTTP response to the POST request to acknowledge receipt of the event. **Timeout:** Prelude will wait up to **10 seconds** for your endpoint to respond. If your endpoint doesn't respond within this timeframe or returns a non-200 status code, the request will be considered failed. **Retries:** Failed requests will be retried with exponential backoff for up to 2 weeks. Retries are spaced progressively further apart (1 min, 2 min, 4 min, ... up to 12 hours) to allow your endpoint time to recover if it's temporarily down. ## Webhook signature To ensure the authenticity of the webhook events, we use a signature mechanism. The signature is a **base64 URL-encoded RSASSA-PSS on the SHA256 hash of the payload**, using the your application's access token signing secret as the key. The signature is sent as a string prefixed with `rsassa-pss-sha256=` in the `X-Webhook-Signature` header of each request to your webhook endpoint. You can get the public key to verify the signature from the [JWKS endpoint](/auth/documentation/jwks) of your application. You can then verify the signature of the webhook events in your webhook endpoint and process the event only if the signature is valid. ## Webhook events You can subscribe to the following events: * `user.created` * `user.deleted` * `user.profile.updated` * `user.identifier.created` * `user.identifier.deleted` * `user.session.created` * `user.session.revoked` * `user.passkey.registered` * `user.passkey.deleted` * `user.passkey.assertion_failed` * `migration.hook_failed` * `step_up.hook_failed` ## Webhook payload Events are received in batches. Each batch contains a list of events and only contains events for the application that the webhook is registered to. ## IP Whitelisting You should whitelist the following IP addresses to ensure that your webhook endpoint receives events from Prelude: ``` 34.252.67.209 52.30.192.161 34.248.153.151 ``` # Activation Challenge Request Source: https://docs.prelude.so/auth/documentation/webhooks/verification-event Handle the activation challenge GET request. When you register a webhook and [activate it](/session/api-reference/management/webhooks/activate-webhook), Prelude will send you an activation challenge request. This is a GET request to the webhook endpoint with the following query parameters: * `event`: The event type. It will be always `activate`. * `verification_token`: The verification token, you set when you registered the webhook. * `challenge`: A random integer. * `app_id`: The application ID of the webhook. To ensure a secure integration, when you receive this request you should: 1. Verify that the `app_id` is the one you'd expect. 2. Verify that the `verification_token` is the one you set when you registered the webhook. 3. Return a `200 OK` response with the challenge integer in the response body. The webhook will be activated only after the verification challenge request is successful. If the verification challenge request fails, the [`GET /v2/session/apps/{appID}/webhooks/{webhookID}/activate`](/session/api-reference/management/webhooks/activate-webhook) will fail with a `400 Bad Request` error and the webhook will not be activated. Once the webhook is activated, you will start directly receiving events from Prelude. ## Activation challenge response The activation challenge response is a JSON object with the following properties: # C# Source: https://docs.prelude.so/introduction/backend-sdks/csharp # Go Source: https://docs.prelude.so/introduction/backend-sdks/go # Kotlin/Java Source: https://docs.prelude.so/introduction/backend-sdks/java # Node.js Source: https://docs.prelude.so/introduction/backend-sdks/node # PHP Source: https://docs.prelude.so/introduction/backend-sdks/php # Python Source: https://docs.prelude.so/introduction/backend-sdks/python # Ruby Source: https://docs.prelude.so/introduction/backend-sdks/ruby # Changelog Source: https://docs.prelude.so/introduction/changelog Changes made to the Prelude platform. ## Verify * 🇨🇦 Added line type validation for Canadian numbers, so landlines are caught before a send. * 📈 The verification volume chart now splits into valid, blocked, invalid, shadowed, and challenged. * 🛡️ Strengthened antifraud protections for more accurate blocking. * 🇮🇷 Added Persian templates for Iranian numbers. * 📁 Added Delivered and Device ID columns to verification CSV exports, plus a "Failed to send" status on failed attempts. ## Notify * 💬 Added RCS for transactional messages in Mexico. * 📮 You now get an email when a template is approved or rejected, with the reason. * 📄 Template submission flags the example CSV when it is uploaded unchanged. ## Auth * 🔑 Prelude Auth can now act as an OAuth2 authorization server, with a consent screen, client registration, and the client credentials grant. * 🏢 Added enterprise OIDC single sign-on, resolved by email domain or connection ID. * 👥 Added groups: define a set of scopes once, assign users to it, and those scopes land in their access tokens. * 🔗 Added LinkedIn as a social login provider. * ⬆️ Added a management endpoint to import pre-hashed passwords when migrating from another system. ## Intel * 🌍 More accurate number portability data on US and Canada lookups. ## Platform * 🪝 More resilient webhook delivery when your endpoint degrades. * 📱 Released a standalone React Native SDK for non-Expo apps. * ⚡ Faster dashboard page loads. * 🔄 The dashboard now prompts you to reload when a new version ships. ## Verify * 🛍️ Added PSD2 SCA dynamic linking support for EU verifications. * 🛡️ Shipped Custom Fraud Protection Settings per country. * 📮 Implemented self-serve email domain deletion and DNS refresh. * 📁 Added device model, IP country, IP region, and fraud block reason to verification CSV exports. ## Notify * 🧾 Pushed a detailed delivery status view for messages. * 🌍 Added Norwegian (nb-NO) locale support for Notify templates. ## Auth * 🫆 Added passkey support for passwordless login, with SDK, management API, and webhooks. * 🎟️ Added SAML single sign-on, with JumpCloud support and enforced login for allowlisted email domains. * 📱 Added Google sign-in and email-link login across mobile Auth SDKs. * ⬇️ Added migrate support to the React Native, Flutter, and Android Auth SDKs. * 🔑 Added a React hooks layer (useAuth, useSignIn, useStepUp) to the React Native Auth SDK. ## Platform * 🏢 Added Organization-level access control (RBAC) with multi-org support in the dashboard. * 🧾 Customer invoices are now viewable directly in the dashboard. ## Verify * 🌍 Added Swedish locale support. * 🛡️ Strengthened antifraud protections for more accurate blocking. ## Auth * 🔐 SAML single sign-on with Okta and Google Workspace support. * 📱 Released the React Native Session SDK; mobile SDKs gain session listing, revocation, and password reset. * 🪝 New webhooks for migration and step-up hook failures, with typed failure reasons. * 🔍 Batch user lookup by email or external ID. * 🖥️ Dashboard: full user management actions on user details (edit profile, manage identifiers, revoke devices, log out everywhere). ## Intel * ⚡ Faster, more resilient lookups with automatic failover across data sources. ## Platform * 🚀 Auth (formerly Session) and Email OTP are now available to all customers. * 🎨 Dashboard: redesigned All Services page, refreshed product icons, reworked region selector, and shareable Overview links. ## Verify * 🛡️ Blocked responses now expose the antifraud risk factors that triggered the block. * 🧪 Test phone numbers can be managed through the management API. * 🔒 Antifraud: signed SDK keys can be locked to a specific platform (iOS, Android, Web). * 📶 Silent Verification: improved iOS and Android carrier compatibility for higher success rates. * 📊 Dashboard: channel filter and carrier (MCC/MNC) details on phone verification views. ## Notify * 💬 Launched 2-way messaging — reply to inbound user messages through the Notify API. * 🖼️ Added image and video media support for WhatsApp messages. * 🚫 Additional opt-out keywords (END, CANCEL, QUIT, UNSUBSCRIBE) on top of STOP. * 🪝 Webhooks: dedicated carrier-disconnect event, distinct from user STOP. ## Auth * 📱 Launched iOS and Android Session SDKs, plus an initial Flutter SDK. * 🔑 Added Facebook OAuth login, with an extra OTP step for unverified social emails. * 🔁 Migrate logged-in users from your existing auth system in a single API call. * ⬆️ Step-up authentication now supports static scope configuration without a webhook. * 🆔 Added external ID on user profiles — settable, filterable, and mappable into JWT claims. * 🖥️ Dashboard: new Session pages with user list and user details. ## Platform * 📈 Dashboard: Overview now offers per-country split graphs and 10-minute zoom granularity. * 📅 Dashboard: date range picker available on every view, and unified delivery status labels across Verify and Notify. * ⚙️ Dashboard: new SDK keys configuration page. * ✉️ Dashboard: custom MAIL FROM subdomain for Email OTP with per-section DNS record export. ## Verify * 🎨 Customizable branding for Email and SMS OTP messages. * 🌍 Added Mongolian and Flemish locale support. * 🛡️ Antifraud: at-risk traffic is now automatically routed to fraud-resistant channels instead of SMS. * ✉️ Dashboard: full Email OTP support with onboarding, overview, and lifecycle views. * ✅ Dashboard: RCS read receipts now visible in verification details. ## Notify * 🪄 Dashboard: Notify onboarding wizard for faster setup. ## Auth * ✉️ Added email OTP login — users can now sign in with a one-time code sent to their email. * 🎫 Custom JWT claims — configure which user data fields appear in your access tokens. * 🔐 Added change password and password management APIs. * 🔄 JS SDK now supports step-up authentication and automatic token refresh. ## Platform * 📶 Frontend SDK: improved iOS carrier compatibility and Web SDK device signals. ## Verify * ✉️ Added email verification configuration. * 🛟 Implemented built-in automated fallback for protected Sender ID. * 🛡️ Antifraud: enhanced device detection rules, fixed a fingerprint scoring issue, and introduced configurable device platform blocking. * 🇪🇸 Silent Verification: expanded coverage to Spain. * 🔁 Silent Verification: implemented granular auto-retries for authentication redirects. * 📊 Dashboard: added Email OTP verification section with overview, details, and lifecycle views. ## Notify * 💬 Added RCS channel support. * 📎 Added PDF attachment support for WhatsApp messages. * 🔏 Added webhook signature on transactional API. * 🪄 Dashboard: added Notify template wizard with region selection and company info autocomplete. ## Auth * 🔐 Improved session security with proof-of-possession tokens. * 🔑 Launched email-password authentication, including sign-up, login, and configurable password rules. * ⬆️ Introduced step-up authentication with configurable scopes. * 🚦 Released session listing and revocation APIs, and added sign-up rate limiting. ## Platform * 🔍 Dashboard: added crosshair zoom on charts, search by region code, and signals modal. * ⚡ Frontend SDK: shipped performance optimizations delivering significantly lower latencies on Android and iOS. * 📱 Frontend SDK: updated React Native and Flutter SDKs to 0.4.x. * 🐘 Backend SDK: released PHP SDK. * 📡 Updated status page — subscribe to updates via Email, RSS, or Slack. ## Verify * 🌐 Added WebOTP support for seamless browser-based verification. * 🔑 Added Auth0 passwordless authentication support. * 🛡️ Antifraud: added device emulator detection heuristic. * 🕵️ Antifraud: refined VPN detection logic to reduce false positives. * 💳 Antifraud: added PSD2 transaction exemption for trusted payment traffic. * 🧭 Dashboard: added routing configuration page and verification detail views. * 🪝 Webhooks: added missing ID and reason fields to verification events. ## Notify * 🔢 Exposed segment count and encoding in SMS API responses and webhooks. * 🌍 Added localization support for subscription management. * 🏷️ Added template ID to webhook events. * 🌐 Added locale field to transactional messages. * 📜 Dashboard: added Notify messages history page with template filtering and delivery status. ## Watch * 🔮 Added reason field to predict outcome responses. ## Intel * ⚠️ Added unassigned phone number error in API responses. ## Auth * 🔑 Added OAuth login with Google, Apple, Microsoft, GitHub, and Okta providers. * ✉️ Added email and password authentication support. * 🔄 Introduced enforced refresh access tokens and session revocation. ## Platform * 🚀 Released the new Dashboard as generally available with mobile-responsive design. * 📱 Frontend SDK: released Android SDK 0.3.0 and Apple SDK 0.3.0 with improved resilience. ## Verify * ⚙️ Added configurable SMS deactivation per flow with custom integration headers support. * 🛡️ Antifraud: added IP concentration detection to prevent SMS pumping prior to sending. * 🌍 Silent Verification: expanded availability to Germany and Spain. * 🔁 Routing: improved automated retries for routing failures. ## Notify * 🚫 Added customizable opt-out messages and MO numbers selection by provider and country. * 🔄 Added automated synchronization for WhatsApp customer templates and improved error handling. ## Intel * 🔌 Integrated new provider for improved reliability. ## Platform * 🚀 Released brand-new Dashboard with simpler navigation, team invites, Stripe top-ups, and CSV exports by email. * 📱 Frontend SDK: shipped Flutter SDK on pub.dev. Improved mobile SDK resiliency with automatic retries and graceful handling of native failures. * ⚡ Improved signals collection pipeline speed to reduce latencies. ## Verify * 🛡️ Antifraud: strengthened protection with new high-risk carrier rules. * 🇫🇷 Silent Verification: improved error handling for French carriers (Orange, Bouygues). * 🪝 Webhooks: exposed channel information in Verify API webhook events. ## Notify * 💬 Enabled WhatsApp integration for all customers. * 📬 Added subscription management endpoints with country code support and webhook 'submitted' events for delivery tracking. ## Platform * 📚 Documentation: published Notify API docs with Marketing features and improved Verify API documentation. ## Verify * 🛡️ Antifraud: strengthened protection with platform-specific rules, smarter detection of artificial traffic, and refined JA4-based fingerprint analysis. ## Notify * 🚀 Launched a complete subscription management system with smart opt-in/out flows, inbound STOP handling, carrier disconnect recovery, and scheduled messaging (schedule\_at). Introduced preferred channel routing and improved provider selection for higher deliverability and lower costs. * 💬 RCS: refined STOP behavior and agent selection for more reliable two-way communication. ## Auth * 🔐 Enhanced security with device identification, session count limits, and safer concurrency handling. ## Platform * ⚡ Dashboard: boosted performance with extended caching and smoother live metric updates. * 🌐 Web SDK: integrated silent verification, added Next.js compatibility, and improved resilience on unstable networks. * 📋 Added flexible allow/blocklist management, smarter webhook retries, and refreshed developer documentation. * 🛠️ Updated provider guides, optimized billing performance, and fine-tuned internal anti-fraud and delivery systems. ## Verify * 🆔 New Sender ID registration management API. * 🔍 Antifraud: exposed new JA4 fingerprint signal field. * 🌍 Routing: rolled out new granular algorithm worldwide, resulting in a \~2% conversion rate uplift. * 🔌 Released new Auth0 integration with verification check support. ## Auth * 🔢 Exposed OTP code size option in the API. ## Platform * 🇫🇷 Frontend SDK: adapted iOS and Android implementation for Bouygues support in France. * 🕵️ Frontend SDK: added ability to detect VPNs and proxies. * 🤖 Frontend SDK: added support for 16KB page sizes on Android. * 📶 Web SDK: mitigated session disconnections upon network disruptions. ## Verify * 🛡️ Antifraud: expanded high-risk country coverage and refined checks. * 🇫🇷 Silent Verification: expanded availability to France for SFR. ## Auth * 🚦 Identifier limit enforcement and enhanced security. ## Platform * 🛟 Web SDK: improved fallback behavior and optimized dispatch flow. * 📚 Documentation: added email method, amended trusted user notice. ## Verify * 🔁 Optimized retry logic and error handling for better reliability. * 🛡️ Antifraud: advanced anti-fraud capabilities. ## Platform * 📈 Dashboard: enhanced analytics with improved performance and real-time data accuracy. * 🎨 Dashboard: UI improvements for better user experience. * 🔒 Frontend SDK: improved security measures. * 📶 Frontend SDK: enhanced silent verification support across all platforms. * 🔏 Webhooks: enhanced support with improved signature verification. * 📚 Documentation: comprehensive updates for SDK integration. ## Verify * 🐛 Fixed retry logic creating extra auth after limit was hit. * 🚀 Silent Verification: launched as a new product, initially available in France for Orange. ## Notify * 💬 WhatsApp transactional messages. ## Platform * 🐛 Dashboard: fixed KPI discrepancies within analytics. * 📈 Dashboard: improved analytics accuracy and speed. * 📶 Frontend SDK: Silent Verification support. * 🔒 Frontend SDK: improved security and Wasm code sandboxing. * 💎 Backend SDK: Ruby support. * 🔔 Credit limit email alerting. * 📚 Documentation: SDK integration documentation. ## Verify * ⏱️ Improved preferred channel scheduling. * 🛡️ Antifraud: improved history-based conversion. * 📈 Routing: new pipeline for improved conversion. ## Notify * 📨 New channel: Telegram. ## Auth * 🌐 Custom domains support. ## Platform * 🖼️ Dashboard: change your application name and profile picture. * 🔒 Frontend SDK: enforcement of verified attempts. * 📶 Frontend SDK: silent verification support for iOS, Android and React Native. * 💰 Prelude pricing simulator on the website. * 📚 Documentation: added more information about blocked response on create endpoint. ## Verify * 🎯 Preferred channel: ability to influence channel selection at the attempt level. ## Notify * 💬 Dashboard: WhatsApp integration module. ## Watch * 🚀 New product launched: Watch API, standalone anti-fraud. ## Auth * 🌐 Session SDK for web and React. ## Platform * 🔏 Webhooks: added signature support. ## Verify * 📞 New channel: voice OTP. * 🧭 Routing: improved granularity. ## Auth * 🚀 New product launched: Session API, session management by Prelude. ## Platform * ⚡ Dashboard: improved data loading performance and accuracy. * 🌐 Frontend SDK: released the Web SDK. * 🪝 Webhooks: support for API v2. ## Verify * 🔤 Alphanumeric code support. * 🛡️ Antifraud: new abusive prefix heuristic. * 📶 Silent Verification: first end-to-end integration. ## Notify * 💬 New WhatsApp direct route. ## Platform * 💰 New pricing page per country and website translations. ## Verify * 🛡️ Antifraud: new history-based conversion heuristic. ## Platform * 🔐 Dashboard: SAML support. * 📱 Frontend SDK: improvements across SDKs. * 🔌 Supabase integration. * 🔌 Auth0 integration. * 💳 Faster and improved billing pipeline. * 🌐 New website. ## Verify * 👋 Dashboard: simplified onboarding for Prelude Verify. ## Platform * 🚨 Spending limits: circuit breaker in case of fraud. * 📱 Frontend SDK: React Native mobile SDK. ## Verify * ✉️ Email support in Verify API. * 🛡️ Antifraud: new temporary phone number database. * 🕵️ Antifraud: new residential proxy heuristic. ## Notify * 💬 New channel: Zalo. ## Platform * 📊 Dashboard: real-time statistics. * 📱 Frontend SDK: released iOS and Android mobile SDKs. * ⚙️ API v2: standardized Verify and Transactional endpoints, easier to integrate and use. # Android SDK Source: https://docs.prelude.so/introduction/frontend-sdks/android Learn how to use our client side Android SDK. It is published in Maven Central (`so.prelude.android:android-sdk`) and the code accessible at [GitHub](https://github.com/prelude-so/android-sdk). ### Usage The Android SDK allows you to capture certain device signals that will be reported back to Prelude to help fight fraud. It will also allow you to perform silent verification of mobile devices. It is provided as a regular Maven artifact that you can use as a normal dependency in your Android application, add it as an implementation dependency: ```kts Kts theme={null} implementation("so.prelude.android:sdk:0.6.2") ``` ```groovy Groovy theme={null} implementation 'so.prelude.android:sdk:0.6.2' ``` ### Requirements * Android minimum SDK **API 26** (Android 8.0) * Java **8** source and target compatibility (Kotlin `jvmTarget` 1.8) To use the SDK you will need the SDK key that you generate in the [Prelude dashboard](https://app.prelude.so/) for your account. When it is created, copy it and keep it somewhere you can retrieve it later, as the dashboard will only show the SDK key once right after it is created. If you lose the key you will need to generate a new one for future use. The SDK key is a publishable, client-side key that ships inside your app binary, so this is about not losing it — not about keeping it secret. #### Capturing Signals To capture device signals you just need to configure it with your SDK key and call a single dispatch function: ```kotlin Kotlin theme={null} coroutineScope.launch { val prelude = Prelude(Configuration(context = context, sdkKey = "sdk_XXXXXXXXXXXXXXXX")) val dispatchId: String? = prelude.dispatchSignals().getOrNull() ... // Use the dispatchId to report it back to your API } ``` ```java Java theme={null} Prelude prelude = new Prelude(new Configuration(context, "sdk_XXXXXXXXXXXXXXXX")); prelude.dispatchSignals((status, dispatchId) -> { if (status == DispatchStatusListener.Status.SUCCESS) { // TODO } }); ``` The `dispatchSignals` function will capture the device signals and report them to Prelude. It will return a `dispatchId` string that you should report back to your back-end to enhance the phone number verification process. As context it is recommended to pass the application context but you can pass any Android context and the library will resolve the correct one. There is no restriction on when to call this API but it is recommended to perform it early in the onboarding process. The recommended way of integrating it is to call the `dispatchSignals` function before displaying the phone number verification screen in your application. This way you can ensure that the device signals are captured and the `dispatchId` can be sent to your back-end with the phone number. Your back-end will then perform the verification call to Prelude with the phone number and the dispatch identifier. This way you can continue the onboarding only when you are sure the signal dispatching is successful. #### Silent Verification The Silent Verification feature allows you to verify a phone number without requiring the user to manually enter a verification code. It is available for certain carriers and requires a server-side service to handle the verification process. For this verification method to work properly, you *must* collect the device signals mentioned before and report the dispatch identifier to your back-end (usually in your APIs verification endpoint). Please refer to the [Silent Verification documentation](https://docs.prelude.so/verify/v2/documentation/silent-verification) for more information on how to implement this feature. #### Proguard If you use minification in your application (i.e. `isMinifyEnabled = true` somewhere in your `build.gradle` file), the SDK automatically provides rules that will be integrated into your project. If you find any Proguard runtime issues, these are the required rules for JNA: ``` -dontwarn java.awt.* -keep class com.sun.jna.** { *; } -keep class * implements com.sun.jna.** { *; } -keepclassmembers class * extends com.sun.jna.* { public *; } ``` # Apple SDK Source: https://docs.prelude.so/introduction/frontend-sdks/apple Learn how to use our client side Apple SDK. The Apple SDK is published at [GitHub](https://github.com/prelude-so/apple-sdk). If you find issues uploading your app to the Apple App Store regarding the PreludeCore.xcframework [please follow these steps.](#uploading-your-app-to-the-app-store) ### Usage The Apple SDK allows you to capture certain device signals that will be reported back to Prelude and perform silent verification of mobile devices. It is provided as a regular Swift package that you can [import as a dependency directly into your iOS application](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app). ### Requirements * iOS deployment target **15.0+** * Xcode **15** or later #### Gathering Device Signals Usage of the SDK to gather the signals is very simple, you just need to configure it with your SDK key and call a single dispatch function: ```objective-c theme={null} let configuration = Configuration(sdkKey: "sdk_XXXXXXXXXXXX") let prelude = Prelude(configuration) let dispatchID = try? await prelude.dispatchSignals() ``` Once you get the dispatch ID you should report it back to your own back-end API to be forwarded in subsequent network calls. There is no restriction on when to call this API, you just need to take this action before you need to report back the dispatch ID. The recommended way of integrating it is to call the `dispatchSignals` function before displaying the phone number verification screen in your application. This way you can ensure that the device signals are captured and the `dispatchID` can be sent to your back-end with the phone number. Your back-end will then perform the verification call to Prelude with the phone number and the dispatch identifier. #### Silent Verification The Silent Verification feature allows you to verify a phone number without requiring the user to manually enter a verification code. It is available for certain carriers and requires a server-side service to handle the verification process. For this verification method to work properly, you must gather the device signals mentioned before and report the dispatch identifier to your backend (usually in your APIs verification endpoint). Please refer to the [Silent Verification documentation](https://docs.prelude.so/verify/v2/documentation/silent-verification) for more information on how to implement this feature. #### CocoaPods integration We currently do not offer CocoaPods integration for the SDK, but creating one is a relatively straightforward process. The steps to take are: * Download the source code from this repository, for example to a directory like `prelude-apple-sdk/sdk`. * Open the `Package.swift` file and copy the url for the Core sdk defined in the binary target (similar to `https://prelude-public.s3.amazonaws.com/sdk/releases/apple/core/X.X.X/PreludeCore-X.X.X.xcframework.zip`). * Download the Core SDK from that url and unzip it to a subdirectory of the one above (for example `prelude-apple-sdk/sdk/core`). You should have a single subdirectory `PreludeCore.xcframework` under `prelude-apple-sdk/core` * Remove the `Package.swift` file. * Create a new `podspec` file in the root of the `prelude-apple-sdk` directory with the following content: ```ruby theme={null} Pod::Spec.new do |s| s.name = 'PreludeAppleSDK' s.version = 'X.X.X' # Update this to the version of the SDK s.summary = 'Prelude Apple SDK' s.license = 'Apache-2.0' s.author = 'Prelude (https://github.com/prelude-so)' s.homepage = 'https://github.com/prelude-so/apple-sdk' s.platforms = { :ios => '15.1' } s.swift_version = '5.4' s.source = { git: 'https://github.com/prelude-so/apple-sdk' } s.static_framework = true s.vendored_frameworks = 'sdk/core/PreludeCore.xcframework' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'SWIFT_COMPILATION_MODE' => 'wholemodule' } s.source_files = "**/*.swift", "../sdk/**/*.swift" end ``` The final directory structure should look like this: Import this `.podspec` file into your project and run normally. With these steps you should have a working version of the SDK in your project. #### Uploading your app to the App Store Depending on the mechanism that you use to upload your app to the App Store, you may find errors related to the `PreludeCore.xcframework` file. If you encounter this error, try uploading the app again with the flag `--use-old-altool`: ``` xcrun altool --upload-app --type ios --file "path/to/your/app.ipa" --apiKey "YourAPIKey" --apiIssuer "YourIssuerID" --use-old-altool ``` If you use Fastlane to build your app, you may want to configure it instead in your lane similar to this: ```ruby theme={null} before_all do ..... ENV["DELIVER_ALTOOL_ADDITIONAL_UPLOAD_PARAMETERS"] = "--use-old-altool" .... end ``` # Flutter SDK Source: https://docs.prelude.so/introduction/frontend-sdks/flutter Learn how to use our client side Flutter SDK. If you find issues uploading your app to the Apple App Store regarding the PreludeCore.xcframework [please follow these steps.](#uploading-your-app-to-the-app-store) It is available in [pub.dev](https://pub.dev/publishers/prelude.so/packages) and the code accessible at [GitHub](https://github.com/prelude-so/flutter-sdk). ## Usage The Flutter SDK enables your application to capture device signals that are sent to Prelude to enhance the fraud detection process. It also provides the functionality to allow your application to use the [silent‑verification feature](https://docs.prelude.so/verify/v2/documentation/silent-verification). Add the SDK to your Flutter application project by declaring it as a dependency in **pubspec.yaml**: ```yaml theme={null} dependencies: prelude_flutter_sdk: ^[VERSION] # replace with the latest version ``` ### Requirements * iOS deployment target **15.1+** * Android minimum SDK **API 26** * Dart **3.9.2+** (`^3.9.2`) / Flutter **3.3+** ### Capturing Signals To collect device signals simply create an instance of `PreludeFlutterSdk` and call `dispatchSignals`. The call returns a `Future` that resolves to a **dispatch ID** which you should forward to your back‑end. The SDK functions return `Futures` so the snippets listed here wrap them in async functions. Adjust to your code base accordingly. The most basic usage is as simple as: ```dart theme={null} import 'package:prelude_flutter_sdk/prelude_flutter_sdk.dart'; Future collectSignals() async { final prelude = PreludeFlutterSdk(); try { final dispatchId = await prelude.dispatchSignals( sdkKey: 'sdk_XXXXXXXXXXXXXXXX', // ← your SDK key ); // Send the `dispatchId` to your server so it can // be used in verification calls. print('Dispatch ID: $dispatchId'); } catch (e) { // Handle errors (e.g., network failure, timeout) print('Failed to dispatch signals: $e'); } } ``` The SDK allows you to fine-tune some extra arguments depending on your requirements: ```dart theme={null} import 'package:prelude_flutter_sdk/prelude_flutter_sdk.dart'; Future collectSignals() async { final prelude = PreludeFlutterSdk(); try { final dispatchId = await prelude.dispatchSignals( sdkKey: 'sdk_XXXXXXXXXXXXXXXX', // ← your SDK key requestTimeoutMilliseconds: 10000, // optional, defaults to 10 000 ms automaticRetryCount: 3, // optional, defaults to 3 retries with exponential backoff implementedFeatures: [], // required for the silent verification feature signalsScope: SignalsScope.full, // optional, default is `full`, set to `silentVerification` // when using the silent verification feature ); // Send the `dispatchId` to your server // so it can be used in verification calls. print('Dispatch ID: $dispatchId'); } catch (e) { print('Failed to dispatch signals: $e'); } } ``` > **Tip:** There is no need to keep an instance of the `PreludeFlutterSdk()`object. Instantiate it when needed and call the dispatchSignals function during your onboarding process. ### Silent Verification If you want to perform silent verification of a phone number, use the `verifySilent` method. You must first have sent the signals and obtained a `dispatchId`. When initiating the verification process with your backend, you send the dispatchId with the user's phone number. If silent verification is available for that number, you will get back in the verification response a url that you need to pass to the SDK so that it can proceed with the verification. ```dart theme={null} import 'package:prelude_flutter_sdk/prelude_flutter_sdk.dart'; Future performSilentVerification() async { final prelude = PreludeFlutterSdk(); try { final dispatchId = await prelude.dispatchSignals( sdkKey: 'sdk_XXXXXXXXXXXXXXXX', // ← your SDK key implementedFeatures: [Features.silentVerification], // ← required signalsScope: SignalsScope.silentVerification // ← required ); // Start the verification process with your backend // sending the dispatch id and the user's phone number. // You get back a method: silent and a request_url in // the response if silent is available. final code = await prelude.verifySilent( sdkKey: 'sdk_XXXXXXXXXXXXXXXX', // ← your SDK key requestUrl: '[request_url]', // ← the request_url retrieved // from your backend ); // If the silent verification is successful you will get back // a code that you need to send to your backend to check and // complete the authentication flow } catch (e) { // Handle verification errors print('Silent verification failed: $e'); } } ``` Silent verification requires a server‑side component that forwards the request to Prelude, using the `dispatchId` you collected earlier. See the [Silent Verification documentation](https://docs.prelude.so/verify/silent/overview) for full details. #### Uploading your app to the App Store Depending on the mechanism that you use to upload your app to the App Store, you may find errors related to the `PreludeCore.xcframework` file. If you encounter this error, try uploading the app again with the flag `--use-old-altool`: ``` xcrun altool --upload-app --type ios --file "path/to/your/app.ipa" --apiKey "YourAPIKey" --apiIssuer "YourIssuerID" --use-old-altool ``` # Introduction to the Frontend SDKs Source: https://docs.prelude.so/introduction/frontend-sdks/introduction Learn how to use our Frontend SDKs Our Frontend SDKs are available for Android, iOS, Web, React Native and Flutter. They enable you to: 1. Collect device signals to strengthen verification and reduce fraud. 2. Perform [silent verification](/verify/v2/documentation/silent-verification) to verify phone numbers without asking users to enter a code. The Frontend SDKs currently can integrate with the [Verify](/verify/v2/documentation/introduction) and [Watch](/watch/v2/documentation/introduction) APIs. **SDK keys are publishable, client-side keys — safe to expose.** Unlike your backend [API key](/verify/v2/documentation/quickstart), the SDK key is designed to be embedded in client applications. It is expected to be visible in your browser JavaScript bundle, page source, or mobile app binary, and it is safe to keep in front-end deployment config such as CI/CD pipelines and Helm values. **Use a separate SDK key for each platform.** When generating an SDK key in the dashboard, select the device platform (Android, Apple, Web, etc.) that matches your intended use case. Prelude enforces that scoped SDK keys can only be used by matching device platforms, which prevents cross-platform misuse and lets you rotate or revoke a single platform's key independently. ## How device signal collection works The Frontend SDK collects device signals and dispatches them to Prelude. Prelude generates a Dispatch ID from those signals and returns it to the SDK. Forward the Dispatch ID to your backend and include it in requests to Prelude's [Verify](/verify/v2/documentation/introduction) or [Watch](/watch/v2/documentation/introduction) APIs. ## Learn more about each platform's SDK Learn how to integrate the Web SDK into your application. Learn how to integrate the Android SDK into your application. Learn how to integrate the iOS SDK into your application. Learn how to integrate the React Native SDK into your application. Learn how to integrate the Flutter SDK into your application. # React Native SDK Source: https://docs.prelude.so/introduction/frontend-sdks/react-native Learn how to use our client side Expo React Native SDK. If you find issues uploading your app to the Apple App Store regarding the PreludeCore.xcframework [please follow these steps.](#uploading-your-app-to-the-app-store) The React Native Expo SDK is published at [GitHub](https://github.com/prelude-so/react-native-sdk). Prelude ships two React Native SDKs with an identical API. Use `@prelude.so/react-native-sdk` for Expo apps (managed, or bare with Expo modules), and `@prelude.so/react-native-sdk-standalone` for plain React Native apps that don't use Expo. Web behaves the same in both (it delegates to `@prelude.so/js-sdk`). ### Using the Expo React Native SDK The Expo React Native SDK allows you to capture certain device signals (both in Android and iOS) that will be reported back to Prelude and perform silent verification of mobile devices. It is provided as an Expo module that you can integrate into your React Native Expo Application. ### Requirements * iOS deployment target **15.1+** * Android minimum SDK **API 26** (Android 8.0) — if your application has a lower value you need to raise it * Built and tested against Expo SDK **52** / React Native **0.76**; the peer ranges themselves are unpinned ### Setup #### Using NPM The SDK is available in npm. You can install the SDK dependency directly using `npm`: ``` npm install @prelude.so/react-native-sdk ``` #### Using Bun Bun requires you to trust the `postinstall` script: ``` bun add --trust @prelude.so/react-native-sdk ``` #### Using pnpm pnpm v10+ requires explicit permission for `postinstall` scripts: ``` pnpm add --allow-build=@prelude.so/react-native-sdk @prelude.so/react-native-sdk ``` You will need to have the Prelude SDK key that you generate in the [Prelude dashboard](https://app.prelude.so/) for your account. ***Important: When you generate the SDK key in the Prelude dashboard, you will be able to copy it, so keep it somewhere you can retrieve it later, as the dashboard will not allow you to display the same key again. The SDK key is a publishable, client-side key that ships inside your app, so this is about not losing it — not about keeping it secret.*** Because the React Native SDK bridges into the native iOS and Android SDKs at runtime, SDK keys are scoped per platform. Generate one key with the **Apple** platform and one with the **Android** platform in the dashboard, then select the appropriate key at runtime based on `Platform.OS`. #### Gathering Device Signals **Note**: Starting with v0.3.0 of the SDK, we have removed the status event and made the `dispatchSignals` function return a promise that resolves to the dispatch identifier, simplifying its usage. To collect the device signals in your application, you can use code like this: ```typescript theme={null} ... // Import the SDK types import * as PreludeReactNativeSdk from '@prelude.so/react-native-sdk'; ... ... // Submit the signals in any of your app event handlers (here is a button example)