Engineering

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.

Honam Kang4 min read

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:

  1. External drive disconnected. Resolving a bookmark for a path on /Volumes/MyExternalSSD/Projects while the SSD is unplugged returns URL with isStale: true. You can't access it, but you have a recoverable bookmark — when the user re-plugs the drive, re-resolving works.
  2. Network share permission lapse. SMB/AFP shares require the user be logged in. Bookmarks resolve but startAccessing may 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 isStale on 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:

  1. Resolve on background, access on a serial queue. Resolution is allocating-heavy and can be backgrounded. The actual read after startAccessing should be on a queue that you control, not random Task.detached.
  2. Don't share URL instances across actors. It's a @MainActor-friendly type by convention but not by enforcement. Pass the Data (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 Data field with the security-scoped bookmark (Codable, persisted).
  • A short-lived FolderHandle when the tab is visible.
  • A migration step that re-saves on isStale resolution.
  • 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:

  1. Always check isStale. Silent bookmark decay is the slow killer.
  2. Always defer stopAccessing. Leaks compound; future reads break.
  3. 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.

Open source

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

Not strictly. Non-sandboxed apps can read/write anywhere the user can. But App Store distribution requires sandboxing, and the bookmark API works in both contexts — using it preserves your option to ship to the App Store later.

References

  1. [1]
  2. [2]

Ready to try mq-dir?

A native quad-pane file manager built for AI multi-tasking on macOS. Free, MIT licensed, zero telemetry.

v0.1.0-beta.11 · MIT · macOS 14.0+ · github