Skip to content

User-owned app databases on native iOS & Android

There’s no Swift or Kotlin SDK — you don’t need one. The database scope is OAuth 2.1 + PKCE over HTTPS, and the data API is plain JSON, so the platform’s own crypto and HTTP clients are enough. This page is the whole flow in each language.

Same three steps as the SDK recipe: build the authorize URL (with a PKCE challenge), open it, exchange the returned code for a token scoped to the user’s database, then call /v1.

import AuthenticationServices
import CryptoKit
import Security
let clientId = "psqlrp_…"
let redirectUri = "https://app.example.com/auth/callback" // Universal Link
func base64url(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
func randomBytes(_ count: Int) -> Data {
var bytes = Data(count: count)
_ = bytes.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, count, $0.baseAddress!)
}
return bytes
}
struct Grant: Decodable {
let accessToken: String
let database: String
let apiUrl: String
let idToken: String?
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case database
case apiUrl = "api_url"
case idToken = "id_token"
}
}

1 + 2 — sign in (PKCE) and capture the code. On iOS 17.4+, ASWebAuthenticationSession accepts an HTTPS callback directly; on older iOS handle the Universal Link in your SceneDelegate instead.

let verifier = base64url(randomBytes(32))
let challenge = base64url(Data(SHA256.hash(data: Data(verifier.utf8))))
let state = base64url(randomBytes(16))
var comps = URLComponents(string: "https://api.persql.com/oauth/authorize")!
comps.queryItems = [
.init(name: "response_type", value: "code"),
.init(name: "client_id", value: clientId),
.init(name: "redirect_uri", value: redirectUri),
.init(name: "scope", value: "openid database"),
.init(name: "code_challenge", value: challenge),
.init(name: "code_challenge_method", value: "S256"),
.init(name: "state", value: state),
]
let session = ASWebAuthenticationSession(
url: comps.url!,
callback: .https(host: "app.example.com", path: "/auth/callback")
) { callbackURL, error in
guard let callbackURL,
let items = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems,
items.first(where: { $0.name == "state" })?.value == state, // CSRF check
let code = items.first(where: { $0.name == "code" })?.value
else { return }
Task { let grant = try await exchange(code: code, verifier: verifier) /* store + use */ }
}
session.presentationContextProvider = presenter // ASWebAuthenticationPresentationContextProviding
session.start()

3 — exchange the code, then query.

func exchange(code: String, verifier: String) async throws -> Grant {
var req = URLRequest(url: URL(string: "https://api.persql.com/oauth/token")!)
req.httpMethod = "POST"
req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var form = URLComponents()
form.queryItems = [
.init(name: "grant_type", value: "authorization_code"),
.init(name: "code", value: code),
.init(name: "client_id", value: clientId),
.init(name: "redirect_uri", value: redirectUri),
.init(name: "code_verifier", value: verifier),
]
req.httpBody = form.percentEncodedQuery?.data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: req)
return try JSONDecoder().decode(Grant.self, from: data)
}
func query(_ sql: String, _ params: [Any] = [], grant: Grant) async throws -> [String: Any] {
// grant.database is "namespace/db", so the path is /v1/db/namespace/db/query
var req = URLRequest(url: URL(string: "\(grant.apiUrl)/v1/db/\(grant.database)/query")!)
req.httpMethod = "POST"
req.setValue("Bearer \(grant.accessToken)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: ["sql": sql, "params": params])
let (data, _) = try await URLSession.shared.data(for: req)
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
return json["data"] as! [String: Any] // { columns, rows, rowsRead, rowsWritten }
}

Store grant.accessToken (and grant.database) in the Keychain; on later launches skip the OAuth dance and call query straight away.

import android.net.Uri
import android.util.Base64
import androidx.browser.customtabs.CustomTabsIntent
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.MediaType.Companion.toMediaType
import org.json.JSONArray
import org.json.JSONObject
import java.security.MessageDigest
import java.security.SecureRandom
const val CLIENT_ID = "psqlrp_…"
const val REDIRECT_URI = "https://app.example.com/auth/callback" // App Link
fun b64url(bytes: ByteArray): String =
Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
private val http = OkHttpClient()

1 — sign in (PKCE). Keep verifier + state until the redirect returns.

val verifier = b64url(ByteArray(32).also { SecureRandom().nextBytes(it) })
val challenge = b64url(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()))
val state = b64url(ByteArray(16).also { SecureRandom().nextBytes(it) })
val authorize = Uri.parse("https://api.persql.com/oauth/authorize").buildUpon()
.appendQueryParameter("response_type", "code")
.appendQueryParameter("client_id", CLIENT_ID)
.appendQueryParameter("redirect_uri", REDIRECT_URI)
.appendQueryParameter("scope", "openid database")
.appendQueryParameter("code_challenge", challenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("state", state)
.build()
CustomTabsIntent.Builder().build().launchUrl(context, authorize)

2 — capture the redirect in the Activity registered for the App Link (intent-filter with android:autoVerify="true" on app.example.com):

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val data = intent.data ?: return
if (data.getQueryParameter("state") != state) return // CSRF check
val code = data.getQueryParameter("code") ?: return
lifecycleScope.launch { val grant = exchange(code, verifier) /* store + use */ }
}

3 — exchange the code, then query (off the main thread):

data class Grant(val accessToken: String, val database: String, val apiUrl: String)
suspend fun exchange(code: String, verifier: String): Grant = withContext(Dispatchers.IO) {
val form = FormBody.Builder()
.add("grant_type", "authorization_code")
.add("code", code)
.add("client_id", CLIENT_ID)
.add("redirect_uri", REDIRECT_URI)
.add("code_verifier", verifier)
.build()
val req = Request.Builder().url("https://api.persql.com/oauth/token").post(form).build()
http.newCall(req).execute().use { res ->
val j = JSONObject(res.body!!.string())
Grant(j.getString("access_token"), j.getString("database"),
j.optString("api_url", "https://api.persql.com"))
}
}
suspend fun query(sql: String, params: List<Any?> = emptyList(), grant: Grant): JSONObject =
withContext(Dispatchers.IO) {
val body = JSONObject().put("sql", sql).put("params", JSONArray(params))
.toString().toRequestBody("application/json".toMediaType())
// grant.database is "namespace/db" → /v1/db/namespace/db/query
val req = Request.Builder()
.url("${grant.apiUrl}/v1/db/${grant.database}/query")
.addHeader("Authorization", "Bearer ${grant.accessToken}")
.post(body).build()
http.newCall(req).execute().use { res ->
JSONObject(res.body!!.string()).getJSONObject("data") // { columns, rows, ... }
}
}

Store the token in EncryptedSharedPreferences; reuse it on later launches.

Both endpoints follow the same envelope ({ "success": true, "data": … }):

  • POST /v1/db/{namespace}/{db}/batch — body { "statements": [{ "sql", "params" }], "transaction": true }, data is an array of results.
  • GET /v1/db/{namespace}/{db}/tablesdata is [{ "name", "rowCount" }].

The full surface is the REST API reference.