Security-scoped bookmarks on macOS: a deep dive (with the gotchas)
When the user picks a folder, you get permission. When they relaunch your app, you don't — unless you used a security-scoped bookmark. Here's everything that breaks if you didn't.
Sandboxing on macOS is a contract: your app gets to access user-granted resources, no others. The bookmark API is how that grant survives a relaunch. It's also where most file-manager bugs in shipped Mac apps come from.
This is what mq-dir's persistence layer does, why each step is necessary, and what fails if you skip a step.
The lifecycle, end to end
User picks a folder via NSOpenPanel
↓
You receive a URL
↓
You convert it to a security-scoped bookmark (Data)
↓
You persist the bookmark
↓
[time passes — app quits, user restarts Mac]
↓
You read the bookmark from disk
↓
You resolve it back to a URL (handle staleness here)
↓
You call startAccessingSecurityScopedResource()
↓
You read/write
↓
You call stopAccessingSecurityScopedResource()
Skip any step and you break a real user case.
Step 1: User picks via NSOpenPanel
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url else { return }
You get back a URL. The URL is only valid until the next launch unless you bookmark it now.
Step 2: Create a security-scoped bookmark
let bookmark = try url.bookmarkData(
options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess],
includingResourceValuesForKeys: nil,
relativeTo: nil
)
Three options worth knowing:
.withSecurityScope— required for app-scoped bookmarks..securityScopeAllowOnlyReadAccess— if you don't need to write, set this. Reduces blast radius..minimalBookmark— a smaller bookmark, but loses some path-resilience properties. Don't use for long-lived persistence.
The result is Data, which you persist (in our case, inside TabState.folderBookmark).
Step 3: Persist the bookmark
Data is Codable. Drop it in your model. Done.
struct TabState: Codable, Sendable {
var folderBookmark: Data
// ...
}
Step 4: Resolve on next launch
This is where most bugs live. Three failure modes:
var isStale: Bool = false
let url = try URL(
resolvingBookmarkData: bookmark,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
if isStale {
// The bookmark resolved, but the path moved/renamed.
// Recreate the bookmark to keep it healthy.
let fresh = try url.bookmarkData(options: [.withSecurityScope], …)
saveFreshBookmark(fresh)
}
The isStale out-parameter is silent in the success case. If you don't check it, your bookmark gradually decays — every time the user moves the folder, your bookmark gets older and stales harder, until one day it can't be resolved at all.
In mq-dir, we always re-create on stale resolve. Two-line fix that prevents a class of "my saved tab can't open" bugs years later.
Step 5: Start accessing
guard url.startAccessingSecurityScopedResource() else {
throw FileError.accessDenied
}
defer { url.stopAccessingSecurityScopedResource() }
// Now you can read/write
let entries = try FileManager.default.contentsOfDirectory(at: url, …)
The startAccessing call is a counted reference. Each start must have a matching stop. macOS keeps a counter; when it hits the per-process limit, future start calls return false.
defer is mandatory here. Without it, an early return path will leak the access — and after enough leaks, your app starts returning accessDenied for legitimate reads.
Step 6: Long-running access
What if you want to keep a folder accessible for the whole app session, not just one read?
final class FolderHandle {
private let url: URL
private var didStart: Bool = false
init(url: URL) throws {
self.url = url
if !url.startAccessingSecurityScopedResource() {
throw FileError.accessDenied
}
didStart = true
}
deinit {
if didStart {
url.stopAccessingSecurityScopedResource()
}
}
}
Wrap it. Don't expose raw URLs. The handle's deinit guarantees the stop call. mq-dir's pane state owns one of these per visible tab.
Volumes and external drives
Two failure modes that surprise:
- External drive disconnected. Resolving a bookmark for a path on
/Volumes/MyExternalSSD/Projectswhile the SSD is unplugged returnsURLwithisStale: true. You can't access it, but you have a recoverable bookmark — when the user re-plugs the drive, re-resolving works. - Network share permission lapse. SMB/AFP shares require the user be logged in. Bookmarks resolve but
startAccessingmay fail intermittently. Detect via the error code and prompt the user to reconnect.
mq-dir treats both cases the same: show the tab as "unavailable", offer a "reconnect" button that re-resolves and re-tries.
Migration across macOS versions / accounts
The bookmark format changed subtly between macOS 12 → 13 → 14 → 15. In practice this means:
- Bookmarks saved on macOS 13 generally work on 14+.
- Bookmarks saved on 14 may resolve
isStaleon 15. Re-create on resolve. - Cross-account migration (TimeMachine restore to a different Mac) almost always stales bookmarks. The user's first launch on the new machine will re-prompt.
Don't try to be clever. The "re-create on stale" pattern from Step 4 handles all of these uniformly.
Concurrency
Bookmark resolution and startAccessing are NOT thread-safe in the way you'd hope. Two patterns we use:
- Resolve on background, access on a serial queue. Resolution is allocating-heavy and can be backgrounded. The actual read after
startAccessingshould be on a queue that you control, not randomTask.detached. - Don't share
URLinstances across actors. It's a@MainActor-friendly type by convention but not by enforcement. Pass theData(the bookmark) across actors; resolve fresh on the consumer side.
Strict concurrency catches some of these but not all.
What you ship in mq-dir
Every tab in mq-dir holds:
- A
Datafield with the security-scoped bookmark (Codable, persisted). - A short-lived
FolderHandlewhen the tab is visible. - A migration step that re-saves on
isStaleresolution. - A graceful "unavailable" UI when access fails.
Together these cover every bookmark-related bug we've seen across testers in 50+ macOS versions and machines. None of it is glamorous; all of it is necessary.
Three habits worth forming
If you take three things from this post:
- Always check
isStale. Silent bookmark decay is the slow killer. - Always
deferstopAccessing. Leaks compound; future reads break. - Always re-prompt gracefully. When access fails, treat it like the user wants to fix it, not like a programmer error.
The bookmark API is well-designed but not forgiving. Spend an afternoon writing a test harness that simulates stale, unavailable, and disconnected cases, and your file manager will be more correct than 90% of shipped Mac apps.
mq-dir's full bookmark-handling code is open source — Sources/mqdirCore/SecurityScopedBookmark.swift if you want to read it directly.
mq-dir is fully open source.
MIT licensed, zero telemetry. Read the source, file an issue, send a PR.
★ Star on GitHub →Frequently asked questions
References
- [1]
- [2]
Ready to try mq-dir?
A native quad-pane file manager built for AI multi-tasking on macOS. Free, MIT licensed, zero telemetry.
Related posts
Codable migration patterns: schema evolution that doesn't lose user data
Adding a field to a Codable struct in Swift looks innocent. Done wrong, it eats your users' state. Here are five patterns that handle every kind of schema change cleanly.
SwiftUI strict concurrency in 2026: what actually changed (and what to do about it)
Apple turned `SWIFT_STRICT_CONCURRENCY: complete` from optional to default for new projects. Here's what that breaks, what it catches, and the patterns that survive the upgrade.