Routing to Private Content
You can deep-link to anything in your app — private posts, gated dashboards, user-specific records, paywalled articles, multi-tenant resources. ULink resolves the link to a destination; your app decides who's allowed to see it.
TL;DR
The pattern in one snippet — receive the link, check auth, render or deflect:
- Swift
- Kotlin
- Dart
ULink.shared.dynamicLinkStream.sink { data in
guard let slug = data.slug else { return }
if AuthService.shared.isSignedIn, PermissionService.shared.canView(slug) {
navigate(to: slug)
} else {
presentSignIn(thenResumeTo: slug)
}
}.store(in: &cancellables)
ulink.dynamicLinkStream.collect { data ->
val slug = data.slug ?: return@collect
when {
!authService.isSignedIn() -> presentSignIn(targetSlug = slug)
!permissionService.canView(slug) -> showNotAuthorized()
else -> navigateTo(slug)
}
}
ULink.instance.onLink.listen((data) {
final slug = data.slug;
if (slug == null) return;
if (auth.isSignedIn && permissions.canView(slug)) {
router.go('/$slug');
} else {
router.go('/sign-in', extra: {'redirectTo': slug});
}
});
That's the pattern. The rest of this page explains why it works, the step-by-step, and the rough edges.
The core idea: ULink routes, your app authorizes
ULink is the routing layer. Your app is the authorization layer. The two are deliberately separate.
When a user taps a deep link, ULink resolves it to a destination inside your app — typically an in-app route like /post/:id or a deep-link path like acme://list/abc. ULink doesn't know whether the user is signed in, who they are, or what they're allowed to see. It hands the destination to your app, and your app applies whatever auth and permission rules you've already built.
This is a feature, not a workaround. We don't ship a "ULink-flavored auth" because every team brings their own — Firebase Auth, Supabase, Clerk, Auth0, Cognito, a custom JWT service. An opinionated integration would force you to give up choices that are usually deeply embedded in your app. By staying auth-agnostic, ULink composes cleanly with whatever you already have.
The pattern, step by step
1. Create the link
When you create the link, point it at an in-app destination — a route, a slug, or a path that your app already knows how to render. Don't put user IDs, session tokens, or sensitive data in the URL itself.
- Swift
- Kotlin
- Dart
- REST
let params = ULinkParameters.unified(
domain: "links.acme.com",
iosUrl: "acme://post/abc123",
androidUrl: "acme://post/abc123",
fallbackUrl: "https://acme.com/p/abc123"
)
let response = try await ULink.shared.createLink(parameters: params)
val params = ULinkParameters.unified(
domain = "links.acme.com",
iosUrl = "acme://post/abc123",
androidUrl = "acme://post/abc123",
fallbackUrl = "https://acme.com/p/abc123"
)
val response = ulink.createLink(params)
final params = ULinkParameters.unified(
domain: 'links.acme.com',
iosUrl: 'acme://post/abc123',
androidUrl: 'acme://post/abc123',
fallbackUrl: 'https://acme.com/p/abc123',
);
final response = await ULink.instance.createLink(params);
curl -X POST https://api.ulink.ly/sdk/links \
-H "X-App-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "unified",
"domain": "links.acme.com",
"iosUrl": "acme://post/abc123",
"androidUrl": "acme://post/abc123",
"fallbackUrl": "https://acme.com/p/abc123"
}'
2. Receive the link in your app
Your platform-specific deep-link handler receives the destination from ULink. See the platform guides (iOS, Android, Flutter) for the wiring.
3. Check auth and authorize
Before rendering, run your normal auth and permission checks:
- Swift
- Kotlin
- Dart
ULink.shared.dynamicLinkStream.sink { data in
guard let slug = data.slug else { return }
switch (AuthService.shared.isSignedIn, PermissionService.shared.canView(slug)) {
case (false, _): deflectToSignIn(targetSlug: slug)
case (true, false): showNotAuthorized()
case (true, true): navigate(to: slug)
}
}.store(in: &cancellables)
ulink.dynamicLinkStream.collect { data ->
val slug = data.slug ?: return@collect
when {
!authService.isSignedIn() -> deflectToSignIn(targetSlug = slug)
!permissionService.canView(slug) -> showNotAuthorized()
else -> navigateTo(slug)
}
}
ULink.instance.onLink.listen((data) {
final slug = data.slug;
if (slug == null) return;
if (!auth.isSignedIn) {
deflectToSignIn(targetSlug: slug);
} else if (!permissions.canView(slug)) {
showNotAuthorized();
} else {
navigateTo(slug);
}
});
The check can be as simple as "is the user signed in" or as rich as a full RBAC/ABAC evaluation. ULink doesn't care.
4. Render or deflect
If access is granted, render the destination. If not, deflect to sign-in, paywall, request-access, or whatever your product does for unauthorized users.
Handling the "user isn't signed in" case
This is the most common rough edge. The user opens a link, your app launches, and the user isn't authenticated. Two patterns work well.
Defer the destination in app state. Store the pending slug somewhere (a singleton, persistent storage, or your state management layer) and replay it after sign-in completes.
- Swift
- Kotlin
- Dart
final class DeepLinkRouter {
static let shared = DeepLinkRouter()
private var pending: String?
func handle(_ slug: String) {
if AuthService.shared.isSignedIn {
navigate(to: slug)
} else {
pending = slug
AuthService.shared.presentSignIn { [weak self] in
if let s = self?.pending { self?.navigate(to: s); self?.pending = nil }
}
}
}
}
object DeepLinkRouter {
private var pending: String? = null
fun handle(slug: String) {
if (AuthService.isSignedIn()) {
navigateTo(slug)
} else {
pending = slug
AuthService.presentSignIn {
pending?.let { navigateTo(it); pending = null }
}
}
}
}
class DeepLinkRouter {
static String? _pending;
static void handle(String slug) {
if (auth.isSignedIn) {
navigateTo(slug);
} else {
_pending = slug;
auth.presentSignIn().then((_) {
final s = _pending;
if (s != null) { navigateTo(s); _pending = null; }
});
}
}
}
Use deferred deep linking for the install case. When the link is tapped by someone who doesn't have your app installed, ULink can preserve the destination across the install → first-launch boundary. See the platform guides for the deferred-link APIs.
What NOT to put in the URL
The URL is visible to anyone who has the link, plus anyone who scrapes referer headers, social previews, or share-sheet captures. Treat it as public even when the destination is private.
| Don't | Use instead |
|---|---|
| User IDs, account numbers | Opaque slugs (abc123) generated server-side |
| Session tokens, API keys | Standard auth headers / cookies in your app |
| PII (email, phone, SSN) | An in-app fetch keyed on the slug, after auth |
| Payment / order details | A record ID; fetch the rest after auth |
If you need to attach context to a link (e.g. attribution, A/B variant, campaign), use the parameters field — but again, treat it as visible.
Vertical examples
The pattern is identical across verticals; only the auth check differs.
- B2B SaaS — a shared dashboard link. Your app checks that the recipient is on the workspace and has read permission for the dashboard.
- Social network — a profile or post link. Your app checks visibility (public / followers-only / private) against the viewer's relationship.
- Fintech — a transaction or invoice link. Your app re-authenticates with biometrics, then checks that the recipient owns the record. See Fintech use cases for the security framing.
- Healthcare — a patient record or appointment link. Your app checks HIPAA-compliant access controls. See Health use cases.
- E-commerce — a wishlist or cart-share link. Your app checks whether the resource was set to "shareable" by the owner, and applies any rate limits.
- Education — a course module or assignment link. Your app checks enrollment, cohort, and unlock state.
In every case ULink does the same thing: resolve the link, hand off the destination. The differences live entirely on your side, where they should.
Related
- Idempotent Link Creation — avoid creating duplicate links when the same share button is tapped multiple times
- Receive Links: iOS, Android, Flutter
- REST API Overview