This guide covers implementing AT Protocol OAuth for native mobile applications
(iOS, Android) using @tijs/atproto-oauth.
This library implements the Backend-for-Frontend (BFF) pattern recommended by AT Protocol for mobile apps requiring long-lived sessions. Your server acts as the OAuth client, keeping tokens secure while the mobile app receives a session cookie.
Mobile authentication uses a secure WebView flow:
- App opens a secure browser (ASWebAuthenticationSession on iOS, Custom Tabs on Android)
- User enters their handle and completes OAuth in the browser
- Your server completes the OAuth exchange
- Server redirects to your app's URL scheme with session credentials
- App extracts credentials and stores them securely
This approach keeps OAuth tokens on your server while giving the mobile app a session token for authenticated requests.
Add mobileScheme to your OAuth configuration:
const oauth = createATProtoOAuth({
baseUrl: "https://myapp.example.com",
appName: "My App",
cookieSecret: Deno.env.get("COOKIE_SECRET")!,
storage: new SQLiteStorage(sqliteAdapter(sqlite)),
sessionTtl: 60 * 60 * 24 * 14,
mobileScheme: "myapp://auth-callback", // Your app's URL scheme
});The mobileScheme is the URL your app registers to handle OAuth callbacks.
When /login receives mobile=true:
- The OAuth flow proceeds normally
- After successful authentication, instead of redirecting to a web page, the
callback redirects to your
mobileSchemewith:session_token: Sealed session token for cookie authenticationdid: User's DIDhandle: User's handle
Example callback URL:
myapp://auth-callback?session_token=Fe26.2**abc...&did=did:plc:xyz&handle=alice.bsky.social
In your Info.plist or Xcode project settings, register your URL scheme:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
<key>CFBundleURLName</key>
<string>com.example.myapp</string>
</dict>
</array>Use ASWebAuthenticationSession for secure OAuth:
import AuthenticationServices
class AuthManager: NSObject, ASWebAuthenticationPresentationContextProviding {
private var authSession: ASWebAuthenticationSession?
func startLogin(handle: String) {
// Build login URL with mobile=true
var components = URLComponents(string: "https://myapp.example.com/login")!
components.queryItems = [
URLQueryItem(name: "handle", value: handle),
URLQueryItem(name: "mobile", value: "true")
]
guard let url = components.url else { return }
// Create secure auth session
authSession = ASWebAuthenticationSession(
url: url,
callbackURLScheme: "myapp"
) { [weak self] callbackURL, error in
self?.handleCallback(callbackURL: callbackURL, error: error)
}
authSession?.presentationContextProvider = self
authSession?.prefersEphemeralWebBrowserSession = false
authSession?.start()
}
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow } ?? UIWindow()
}
}Extract credentials from the callback URL:
func handleCallback(callbackURL: URL?, error: Error?) {
if let error = error as? ASWebAuthenticationSessionError,
error.code == .canceledLogin {
// User cancelled - not an error
return
}
guard let url = callbackURL,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems else {
// Handle error
return
}
// Extract credentials
guard let sessionToken = queryItems.first(where: { $0.name == "session_token" })?.value,
let did = queryItems.first(where: { $0.name == "did" })?.value,
let handle = queryItems.first(where: { $0.name == "handle" })?.value else {
// Handle missing parameters
return
}
// Store session token securely (Keychain recommended)
saveToKeychain(sessionToken: sessionToken, did: did, handle: handle)
// Set up cookie for API requests
setSessionCookie(sessionToken: sessionToken)
}The session token is an Iron Session sealed cookie value. Set it as a cookie for API requests:
func setSessionCookie(sessionToken: String) {
let cookie = HTTPCookie(properties: [
.name: "sid",
.value: sessionToken,
.domain: "myapp.example.com",
.path: "/",
.secure: true,
.expires: Date().addingTimeInterval(60 * 60 * 24 * 14) // 14 days
])!
HTTPCookieStorage.shared.setCookie(cookie)
}
// API requests automatically include the cookie
func fetchProfile() async throws -> Profile {
let url = URL(string: "https://myapp.example.com/api/profile")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(Profile.self, from: data)
}In your AndroidManifest.xml:
<activity android:name=".AuthCallbackActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp"
android:host="auth-callback" />
</intent-filter>
</activity>Use Custom Tabs for secure OAuth:
import androidx.browser.customtabs.CustomTabsIntent
fun startLogin(handle: String) {
val url = Uri.parse("https://myapp.example.com/login")
.buildUpon()
.appendQueryParameter("handle", handle)
.appendQueryParameter("mobile", "true")
.build()
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(context, url)
}In your callback activity:
class AuthCallbackActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
intent.data?.let { uri ->
val sessionToken = uri.getQueryParameter("session_token")
val did = uri.getQueryParameter("did")
val handle = uri.getQueryParameter("handle")
if (sessionToken != null && did != null && handle != null) {
// Store securely (EncryptedSharedPreferences recommended)
saveCredentials(sessionToken, did, handle)
// Set up cookie for API requests
setSessionCookie(sessionToken)
}
}
// Return to main app
finish()
}
}fun setSessionCookie(sessionToken: String) {
val cookieManager = CookieManager.getInstance()
val cookie = "sid=$sessionToken; Path=/; Secure; HttpOnly"
cookieManager.setCookie("https://myapp.example.com", cookie)
}After setting the cookie, validate the session by calling your session endpoint:
// iOS
func validateSession() async throws -> Bool {
let url = URL(string: "https://myapp.example.com/api/auth/session")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
return false
}
let session = try JSONDecoder().decode(SessionResponse.self, from: data)
return session.authenticated
}On app launch, restore the session from secure storage:
func restoreSession() {
guard let sessionToken = loadFromKeychain() else {
// No stored session
return
}
// Recreate cookie
setSessionCookie(sessionToken: sessionToken)
// Validate session is still valid
Task {
let isValid = try await validateSession()
if !isValid {
// Session expired, clear and prompt login
clearCredentials()
}
}
}-
Secure Storage: Store credentials in iOS Keychain or Android EncryptedSharedPreferences.
-
URL Scheme: Use a unique scheme unlikely to conflict with other apps. Consider using a reverse-domain format.
-
Ephemeral Sessions: Set
prefersEphemeralWebBrowserSession = falseon iOS to allow SSO with existing Bluesky sessions. -
Token Security: The
session_tokenis cryptographically sealed. It cannot be tampered with or forged. -
Server-Side Tokens: OAuth access/refresh tokens stay on your server. The mobile app only receives a session identifier.
For the best user experience, create a dedicated mobile login page:
// /mobile-auth route
app.get("/mobile-auth", (c) => {
return c.html(`
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in - My App</title>
</head>
<body>
<h1>Sign in to My App</h1>
<form action="/login" method="get">
<input type="hidden" name="mobile" value="true">
<input type="text" name="handle" placeholder="alice.bsky.social" required>
<button type="submit">Continue</button>
</form>
</body>
</html>
`);
});This provides a clean login experience within the secure WebView.
- Verify URL scheme is registered correctly
- Check that
mobileSchemematches your registered scheme exactly - On iOS, ensure
callbackURLSchemematches (without://)
- Verify the session token is being set as a cookie correctly
- Check cookie domain matches your API domain
- Ensure cookies are being sent with requests (
credentials: "include")
- The OAuth state expired (default: 10 minutes)
- User took too long to complete authorization
- Start a new login flow
- OAuth Specification - Full OAuth spec including mobile client requirements
- OAuth Introduction - Overview of OAuth patterns and app types
- BFF Pattern - Backend-for-Frontend architecture details
- React Native OAuth Example -
Official Bluesky mobile example using
@atproto/oauth-client-expo - Go OAuth Web App - BFF pattern implementation in Go
- Python OAuth Web App - BFF pattern implementation in Python
- ATProtoFoundation - Swift package
providing the iOS client-side implementation for this library's BFF pattern,
including
IronSessionMobileOAuthCoordinatorfor handling the OAuth flow andKeychainCredentialsStoragefor secure credential storage.
This library uses the BFF pattern where OAuth tokens stay on your server. If you prefer tokens on the device, consider:
- @atproto/oauth-client-expo - Official Bluesky SDK for React Native (tokens on device)
The BFF pattern is recommended when you need:
- Long-lived sessions (up to 14 days for public clients)
- Server-side API calls on behalf of users
- Simplified mobile client code