Documentation Index
Fetch the complete documentation index at: https://docs.prelude.so/llms.txt
Use this file to discover all available pages before exploring further.
Refresh the access token
Access tokens are short-lived. Callrefresh to obtain a new one without prompting the user. The SDK persists tokens in the platform’s secure store and coalesces concurrent refresh callers into a single request, so the single-use refresh token is never used twice — even when several callers race on the same client.
How is the refresh flow secured?
How is the refresh flow secured?
The refresh flow is protected by DPoP (Demonstration of Proof-of-Possession). The SDK generates a cryptographic key pair on the device — Secure Enclave on iOS, AndroidKeystore (StrongBox / TEE when available) on Android — and signs each refresh request with a proof that binds it to this client. This protects against:
- Token theft — A stolen refresh token is unusable without the private key, which never leaves the device
- Token replay — Each DPoP proof carries 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 — Platform-native keys are non-extractable; the keypair cannot be copied to another device
refresh explicitly: protected requests auto-refresh expired access tokens transparently via the SDK’s interceptor.
- iOS
- Android
- Flutter
- React Native
let user = try await client.refresh()
// user.accessToken — fresh JWT
// user.profile — claims (userID, sessionID, extras)
try await client.invalidateSession()
let profile = await client.profile // PreludeProfile?
let token = await client.accessToken // String?
let expires = await client.accessTokenExpiresAt // Date?
Every method on this page is a To force the next protected call to mint a new token (bypassing the local cache), invalidate it:You can also read the cached token without forcing a refresh:
suspend function — call them from a coroutine, e.g. viewModelScope.launch { ... }.val user = client.refresh()
// user.accessToken — fresh JWT
// user.profile — claims (userId, sessionId, extras)
client.invalidateCache()
val profile = client.getProfile() // PreludeProfile?
val token = client.getAccessToken() // String?
val sessionId = client.getSessionId() // String?
val expires = client.getAccessTokenExpiresAt() // java.time.Instant?
The Dart call delegates to the native SDK on each platform.To force the next protected call to mint a new token (bypassing the local cache), invalidate it:You can also read the cached token without forcing a refresh:
final user = await client.refresh();
// user.accessToken — fresh JWT
// user.profile — claims (userID, sessionID, extras)
await client.invalidateSession();
final profile = await client.getProfile(); // PreludeProfile?
final token = await client.getAccessToken(); // String?
final sessionID = await client.getSessionID(); // String?
final expires = await client.getAccessTokenExpiresAt(); // DateTime?
The JS call delegates to the native SDK on each platform.To force the next protected call to mint a new token (bypassing the local cache), invalidate it:You can also read the cached token without forcing a refresh:
const user = await client.refresh();
// user.accessToken — fresh JWT
// user.profile — claims (userID, sessionID, extras)
await client.invalidateSession();
const profile = await client.getProfile(); // PreludeProfile | null
const token = await client.getAccessToken(); // string | null
const sessionID = await client.getSessionID(); // string | null
const expires = await client.getAccessTokenExpiresAt(); // Date | null
Log out
Revoke the current session on the server and wipe the credentials this client owns.logout is idempotent and concurrent-safe: callers that race onto a single logout share the same in-flight task. Local state is wiped first, so even a failed server round-trip leaves the client locally signed out.
- iOS
- Android
- Flutter
- React Native
try await client.logout()
client.logout()
await client.logout();
await client.dispose();
dispose releases the native plugin’s reference to this logical session. After it returns, every other method on the instance throws StateError.await client.logout();
await client.dispose();
dispose releases the native plugin’s reference to this logical session. After it returns, every other method on the instance throws DisposedError.List sessions
Retrieve all active sessions for the authenticated user. EachPreludeSessionView exposes id, deviceType, deviceModel, osVersion, countryCode, createdAt, lastSeenAt, and expiresAt.
- iOS
- Android
- Flutter
- React Native
let page = try await client.listSessions(
ListSessionsOptions(limit: 10, offset: 0)
)
for session in page.sessions {
print(session.id, session.deviceModel, session.lastSeenAt)
}
Date parsing strategy.val page = client.listSessions(
PreludeListSessionsOptions(limit = 10, offset = 0),
)
for (session in page.sessions) {
println("${session.id} ${session.deviceModel} ${session.lastSeenAt}")
}
deviceType is a PreludeSessionDeviceType enum — UNKNOWN is used for device types added in newer servers. Timestamps are returned as java.time.Instant values.final page = await client.listSessions(
PreludeListSessionsOptions(limit: 10, offset: 0),
);
for (final session in page.sessions) {
print('${session.id} ${session.deviceModel} ${session.lastSeenAt}');
}
deviceType is a PreludeDeviceType enum — unknown is used for device types added in newer servers. Timestamps are returned as DateTime values in UTC.const page = await client.listSessions({ limit: 10, offset: 0 });
for (const session of page.sessions) {
console.log(session.id, session.deviceModel, session.lastSeenAt);
}
deviceType is a string union ("desktop" | "mobile" | "tablet" | "unknown"); the SDK folds unknown server values into "unknown". Timestamps come back as ISO-8601 strings.Try it
Try it
- iOS
- Android
- Flutter
- React Native
Replace
ContentView.swift with:ContentView.swift
import SwiftUI
import PreludeSession
private let appID = "YOUR_APP_ID"
@MainActor
final class SessionsModel: ObservableObject {
let client = try! PreludeSessionClient(
endpoint: .custom("https://\(appID).session.prelude.dev")
)
@Published var sessions: [PreludeSessionView] = []
@Published var loading = true
@Published var loggedIn = false
func load() async {
defer { loading = false }
do {
_ = try await client.refresh()
loggedIn = true
sessions = try await client.listSessions().sessions
} catch {
loggedIn = false
}
}
}
struct ContentView: View {
@StateObject private var model = SessionsModel()
var body: some View {
Group {
if model.loading {
ProgressView()
} else if !model.loggedIn {
Text("Please log in first.")
} else {
List(model.sessions, id: \.id) { s in
VStack(alignment: .leading, spacing: 2) {
Text(s.deviceModel.isEmpty ? s.deviceType.rawValue : s.deviceModel)
.font(.headline)
Text("\(s.countryCode) · last seen \(s.lastSeenAt)")
.font(.caption).foregroundStyle(.secondary)
Text(s.id).font(.caption2.monospaced()).foregroundStyle(.secondary)
}
}
}
}
.task { await model.load() }
}
}
Replace (
MainActivity.kt with:MainActivity.kt
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
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.session.*
import java.net.URL
private const val APP_ID = "YOUR_APP_ID"
class SessionsViewModel(ctx: android.content.Context) : ViewModel() {
val client = PreludeSessionClient(
context = ctx,
baseUrl = URL("https://$APP_ID.session.prelude.dev"),
)
var sessions by mutableStateOf<List<PreludeSessionView>>(emptyList()); private set
var loading by mutableStateOf(true); private set
var loggedIn by mutableStateOf(false); private set
init {
viewModelScope.launch {
try {
client.refresh()
loggedIn = true
sessions = client.listSessions().sessions
} catch (_: PreludeSessionError) {
loggedIn = false
} finally {
loading = false
}
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface {
val vm: SessionsViewModel = viewModel(factory = viewModelFactory(applicationContext))
Box(Modifier.fillMaxSize().padding(24.dp)) {
when {
vm.loading -> CircularProgressIndicator()
!vm.loggedIn -> Text("Please log in first.")
else -> LazyColumn {
items(vm.sessions, key = { it.id }) { s ->
Column(Modifier.padding(vertical = 8.dp)) {
Text(
s.deviceModel.ifEmpty { s.deviceType.wireValue },
style = MaterialTheme.typography.titleSmall,
)
Text("${s.countryCode} · last seen ${s.lastSeenAt}",
style = MaterialTheme.typography.bodySmall)
Text(s.id, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
}
}
}
}
viewModelFactory is the small helper defined in Introduction.)Replace
lib/main.dart with:lib/main.dart
import 'package:flutter/material.dart';
import 'package:prelude_flutter_session_sdk/prelude_flutter_session_sdk.dart';
const appID = 'YOUR_APP_ID';
void main() => runApp(const MyApp());
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final client = PreludeSessionClient(
endpoint: Endpoint.custom('https://$appID.session.prelude.dev'),
);
bool _loading = true;
bool _loggedIn = false;
List<PreludeSessionView> _sessions = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
await client.refresh();
final page = await client.listSessions();
setState(() {
_loggedIn = true;
_sessions = page.sessions;
});
} on PreludeSessionException {
setState(() => _loggedIn = false);
} finally {
setState(() => _loading = false);
}
}
@override
void dispose() {
client.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget body;
if (_loading) {
body = const Center(child: CircularProgressIndicator());
} else if (!_loggedIn) {
body = const Center(child: Text('Please log in first.'));
} else {
body = ListView.builder(
itemCount: _sessions.length,
itemBuilder: (_, i) {
final s = _sessions[i];
return ListTile(
title: Text(s.deviceModel.isEmpty
? s.deviceType.wireValue
: s.deviceModel),
subtitle: Text('${s.countryCode} · last seen ${s.lastSeenAt}'),
dense: true,
);
},
);
}
return MaterialApp(home: Scaffold(body: body));
}
}
Replace
app/index.tsx with:app/index.tsx
import {
Endpoint,
PreludeSessionClient,
PreludeSessionError,
PreludeSessionView,
} from "@prelude.so/react-native-session-sdk";
import { useEffect, useRef, useState } from "react";
import { FlatList, Text, View } from "react-native";
const APP_ID = "YOUR_APP_ID";
export default function Home() {
const clientRef = useRef<PreludeSessionClient | null>(null);
const [sessions, setSessions] = useState<PreludeSessionView[]>([]);
const [loading, setLoading] = useState(true);
const [loggedIn, setLoggedIn] = useState(false);
useEffect(() => {
const c = new PreludeSessionClient({
endpoint: Endpoint.custom(`https://${APP_ID}.session.prelude.dev`),
});
clientRef.current = c;
(async () => {
try {
await c.refresh();
const page = await c.listSessions();
setSessions(page.sessions);
setLoggedIn(true);
} catch {
setLoggedIn(false);
} finally {
setLoading(false);
}
})();
return () => { c.dispose().catch(() => {}); };
}, []);
if (loading) return <Text style={{ padding: 24 }}>Loading…</Text>;
if (!loggedIn) return <Text style={{ padding: 24 }}>Please log in first.</Text>;
return (
<FlatList
data={sessions}
keyExtractor={(s) => s.id}
renderItem={({ item: s }) => (
<View style={{ padding: 12 }}>
<Text style={{ fontWeight: "600" }}>
{s.deviceModel || s.deviceType}
</Text>
<Text>{s.countryCode} · last seen {s.lastSeenAt}</Text>
<Text style={{ fontSize: 12 }}>{s.id}</Text>
</View>
)}
/>
);
}
Revoke sessions
Revoke one or more sessions withrevokeSessions. The revoke target carries which sessions to revoke in the type system — the per-session case requires an id.
| Target | Description |
|---|---|
all | Revoke every session for this user, including the current one. |
others | Revoke every session except the current one. |
mine | Revoke the current session only. |
session(id) | Revoke one specific session by id. |
all, mine, or a session(id) matching the current session), the SDK also wipes the local credentials — the same wipe logout() performs — so a leaked refresh token can’t bring the session back.
- iOS
- Android
- Flutter
- React Native
try await client.revokeSessions(.all)
try await client.revokeSessions(.others)
try await client.revokeSessions(.mine)
try await client.revokeSessions(.session(id: sessionID))
client.revokeSessions(PreludeRevokeTarget.All)
client.revokeSessions(PreludeRevokeTarget.Others)
client.revokeSessions(PreludeRevokeTarget.Mine)
client.revokeSessions(PreludeRevokeTarget.Session(sessionId))
await client.revokeSessions(PreludeRevokeTarget.all);
await client.revokeSessions(PreludeRevokeTarget.others);
await client.revokeSessions(PreludeRevokeTarget.mine);
await client.revokeSessions(PreludeRevokeTarget.session(sessionID));
session constructor rejects empty / whitespace strings up front.import { PreludeRevokeTarget } from "@prelude.so/react-native-session-sdk";
await client.revokeSessions(PreludeRevokeTarget.all);
await client.revokeSessions(PreludeRevokeTarget.others);
await client.revokeSessions(PreludeRevokeTarget.mine);
await client.revokeSessions(PreludeRevokeTarget.session(sessionID));
session(...) factory rejects empty / whitespace strings up front and returns a frozen target.Try it
Try it
Building on the list-sessions example, add revocation controls:(
- iOS
- Android
- Flutter
- React Native
ContentView.swift
import SwiftUI
import PreludeSession
private let appID = "YOUR_APP_ID"
@MainActor
final class RevokeModel: ObservableObject {
let client = try! PreludeSessionClient(
endpoint: .custom("https://\(appID).session.prelude.dev")
)
@Published var sessions: [PreludeSessionView] = []
@Published var loggedIn = false
@Published var error: String?
func load() async {
do {
_ = try await client.refresh()
loggedIn = true
sessions = try await client.listSessions().sessions
} catch {
loggedIn = false
}
}
func revoke(_ target: RevokeTarget) async {
error = nil
do {
try await client.revokeSessions(target)
switch target {
case .all, .mine:
loggedIn = false
sessions = []
default:
sessions = try await client.listSessions().sessions
}
} catch {
self.error = error.localizedDescription
}
}
}
struct ContentView: View {
@StateObject private var model = RevokeModel()
var body: some View {
VStack {
if let error = model.error {
Text(error).foregroundStyle(.red).font(.caption)
}
if !model.loggedIn {
Text("Please log in first.")
} else {
HStack {
Button("Others") { Task { await model.revoke(.others) } }
Button("Mine") { Task { await model.revoke(.mine) } }
.tint(.orange)
Button("All") { Task { await model.revoke(.all) } }
.tint(.red)
}
.buttonStyle(.bordered)
List(model.sessions, id: \.id) { s in
HStack {
VStack(alignment: .leading) {
Text(s.deviceModel.isEmpty ? s.deviceType.rawValue : s.deviceModel)
Text(s.id).font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
Button("Revoke") {
Task { await model.revoke(.session(id: s.id)) }
}
.buttonStyle(.borderless)
}
}
}
}
.task { await model.load() }
}
}
MainActivity.kt
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
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.session.*
import java.net.URL
private const val APP_ID = "YOUR_APP_ID"
class RevokeViewModel(ctx: android.content.Context) : ViewModel() {
val client = PreludeSessionClient(
context = ctx,
baseUrl = URL("https://$APP_ID.session.prelude.dev"),
)
var sessions by mutableStateOf<List<PreludeSessionView>>(emptyList()); private set
var loggedIn by mutableStateOf(false); private set
var error by mutableStateOf<String?>(null); private set
init { viewModelScope.launch { load() } }
private suspend fun load() {
try {
client.refresh()
loggedIn = true
sessions = client.listSessions().sessions
} catch (_: PreludeSessionError) {
loggedIn = false
}
}
fun revoke(target: PreludeRevokeTarget) {
error = null
viewModelScope.launch {
try {
client.revokeSessions(target)
when (target) {
PreludeRevokeTarget.All, PreludeRevokeTarget.Mine -> {
loggedIn = false
sessions = emptyList()
}
else -> sessions = client.listSessions().sessions
}
} catch (e: PreludeSessionError) {
error = e.message
}
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface {
val vm: RevokeViewModel = viewModel(factory = viewModelFactory(applicationContext))
Column(Modifier.fillMaxSize().padding(24.dp)) {
vm.error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
if (!vm.loggedIn) {
Text("Please log in first.")
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { vm.revoke(PreludeRevokeTarget.Others) }) { Text("Others") }
OutlinedButton(onClick = { vm.revoke(PreludeRevokeTarget.Mine) }) { Text("Mine") }
Button(onClick = { vm.revoke(PreludeRevokeTarget.All) }) { Text("All") }
}
Spacer(Modifier.height(16.dp))
LazyColumn {
items(vm.sessions, key = { it.id }) { s ->
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(s.deviceModel.ifEmpty { s.deviceType.wireValue })
Text(s.id, style = MaterialTheme.typography.bodySmall)
}
TextButton(onClick = {
vm.revoke(PreludeRevokeTarget.Session(s.id))
}) { Text("Revoke") }
}
}
}
}
}
}
}
}
}
}
viewModelFactory is the small helper defined in Introduction.)lib/main.dart
import 'package:flutter/material.dart';
import 'package:prelude_flutter_session_sdk/prelude_flutter_session_sdk.dart';
const appID = 'YOUR_APP_ID';
void main() => runApp(const MyApp());
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final client = PreludeSessionClient(
endpoint: Endpoint.custom('https://$appID.session.prelude.dev'),
);
bool _loggedIn = false;
List<PreludeSessionView> _sessions = const [];
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
await client.refresh();
final page = await client.listSessions();
setState(() {
_loggedIn = true;
_sessions = page.sessions;
});
} on PreludeSessionException {
setState(() => _loggedIn = false);
}
}
Future<void> _revoke(PreludeRevokeTarget target) async {
setState(() => _error = null);
try {
await client.revokeSessions(target);
if (target == PreludeRevokeTarget.all ||
target == PreludeRevokeTarget.mine) {
setState(() {
_loggedIn = false;
_sessions = const [];
});
} else {
final page = await client.listSessions();
setState(() => _sessions = page.sessions);
}
} on PreludeSessionException catch (e) {
setState(() => _error = e.message);
}
}
@override
void dispose() {
client.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: !_loggedIn
? const Center(child: Text('Please log in first.'))
: Column(children: [
if (_error != null)
Padding(
padding: const EdgeInsets.all(12),
child: Text(_error!,
style: const TextStyle(color: Colors.red)),
),
Padding(
padding: const EdgeInsets.all(12),
child: Wrap(spacing: 8, children: [
OutlinedButton(
onPressed: () => _revoke(PreludeRevokeTarget.others),
child: const Text('Others'),
),
OutlinedButton(
onPressed: () => _revoke(PreludeRevokeTarget.mine),
child: const Text('Mine'),
),
FilledButton(
onPressed: () => _revoke(PreludeRevokeTarget.all),
child: const Text('All'),
),
]),
),
Expanded(
child: ListView.builder(
itemCount: _sessions.length,
itemBuilder: (_, i) {
final s = _sessions[i];
return ListTile(
title: Text(s.deviceModel.isEmpty
? s.deviceType.wireValue
: s.deviceModel),
subtitle: Text(s.id),
trailing: TextButton(
child: const Text('Revoke'),
onPressed: () => _revoke(
PreludeRevokeTarget.session(s.id),
),
),
);
},
),
),
]),
),
);
}
}
Building on the list-sessions example, add revocation controls:
app/index.tsx
import {
Endpoint,
PreludeRevokeTarget,
PreludeSessionClient,
PreludeSessionError,
PreludeSessionView,
} from "@prelude.so/react-native-session-sdk";
import { useEffect, useRef, useState } from "react";
import { Button, FlatList, Text, View } from "react-native";
const APP_ID = "YOUR_APP_ID";
export default function Home() {
const clientRef = useRef<PreludeSessionClient | null>(null);
const [sessions, setSessions] = useState<PreludeSessionView[]>([]);
const [loggedIn, setLoggedIn] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const c = new PreludeSessionClient({
endpoint: Endpoint.custom(`https://${APP_ID}.session.prelude.dev`),
});
clientRef.current = c;
(async () => {
try {
await c.refresh();
const page = await c.listSessions();
setSessions(page.sessions);
setLoggedIn(true);
} catch {
setLoggedIn(false);
}
})();
return () => { c.dispose().catch(() => {}); };
}, []);
async function revoke(target: PreludeRevokeTarget) {
setError(null);
try {
await clientRef.current!.revokeSessions(target);
if (target.kind === "all" || target.kind === "mine") {
setLoggedIn(false);
setSessions([]);
} else {
const page = await clientRef.current!.listSessions();
setSessions(page.sessions);
}
} catch (e) {
setError(e instanceof PreludeSessionError ? e.message : String(e));
}
}
if (!loggedIn) return <Text style={{ padding: 24 }}>Please log in first.</Text>;
return (
<View style={{ flex: 1 }}>
{error && <Text style={{ color: "red", padding: 12 }}>{error}</Text>}
<View style={{ flexDirection: "row", gap: 8, padding: 12 }}>
<Button title="Others" onPress={() => revoke(PreludeRevokeTarget.others)} />
<Button title="Mine" onPress={() => revoke(PreludeRevokeTarget.mine)} />
<Button title="All" onPress={() => revoke(PreludeRevokeTarget.all)} />
</View>
<FlatList
data={sessions}
keyExtractor={(s) => s.id}
renderItem={({ item: s }) => (
<View style={{ flexDirection: "row", padding: 12, alignItems: "center" }}>
<View style={{ flex: 1 }}>
<Text>{s.deviceModel || s.deviceType}</Text>
<Text style={{ fontSize: 12 }}>{s.id}</Text>
</View>
<Button title="Revoke" onPress={() => revoke(PreludeRevokeTarget.session(s.id))} />
</View>
)}
/>
</View>
);
}
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:https://{app_id}.session.prelude.dev/.well-known/jwks.json