Skip to content
macOSDeep Dive Published Updated 5 min readViews unavailable

NSSecureCoding on macOS: Class Allowlists and Archive Migration

Decode macOS keyed archives with explicit class allowlists, stable keys, and bounded migrations without treating secure coding as authentication or validation.

NSKeyedArchiver stores an object graph as a keyed archive, and NSSecureCoding lets a class declare that it supports constrained decoding. The important security boundary is the decoder’s class policy: when secure coding is required, the archive cannot substitute an arbitrary class for an expected value and have that class instantiated without an explicit allowance.

Secure coding is not encryption, a signature, or proof that archived values are safe to use. It limits which classes may be decoded. The application still has to validate strings, identifiers, paths, collection sizes, and relationships after decoding, especially when bytes came from outside the app’s own protected container.

Require secure coding at the archive boundary

For a custom class, implement NSSecureCoding, return true from supportsSecureCoding, encode fields under stable keys, and decode object fields through class-restricted APIs. The top-level archive operation should also require secure coding. On read, prefer the NSKeyedUnarchiver convenience that requires an expected root class, then validate the decoded object’s fields before applying them.

This example deliberately persists one string. It avoids a nested collection so the allowed class set remains easy to inspect:

import Foundation

final class SavedFilter: NSObject, NSSecureCoding {
    static var supportsSecureCoding: Bool { true }

    let query: String

    init(query: String) {
        self.query = query
        super.init()
    }

    required init?(coder: NSCoder) {
        guard let value = coder.decodeObject(of: NSString.self, forKey: "query") as? String else {
            return nil
        }
        query = value
        super.init()
    }

    func encode(with coder: NSCoder) {
        coder.encode(query as NSString, forKey: "query")
    }
}

let source = SavedFilter(query: "status:open")
let archive = try NSKeyedArchiver.archivedData(
    withRootObject: source,
    requiringSecureCoding: true
)
let restored = try NSKeyedUnarchiver.unarchivedObject(
    ofClass: SavedFilter.self,
    from: archive
)
guard let restored, restored.query.count <= 256 else {
    throw CocoaError(.coderInvalidValue)
}

The NSString allowlist is intentional: it is the archived representation used by this class. If the object later gains an array, dictionary, URL, date, or custom child object, add only the concrete classes the format needs and decode them with the restricted decodeObject(of:forKey:) form. For nested collections, allow the collection class and its permitted element classes; an NSArray allowance alone does not define what should be accepted as its contents.

Keep class policy and value validation separate

NSSecureCoding protects the class-instantiation decision. It does not decide whether a decoded query is meaningful, whether an identifier belongs to the current account, or whether a URL is allowed to access a resource. After decoding, apply normal domain checks: size limits, enum membership, path containment, identifier ownership, and required relationships. Treat a syntactically valid archive as untrusted input if its provenance is not controlled.

Bound archive size before decoding when loading from a file, network response, pasteboard, or sync service. A class allowlist does not prevent an attacker from supplying a very large permitted array or long string. If an archive is used as a durable file, write it through an atomic replacement workflow and keep integrity/authenticity separate: a hash detects accidental corruption only if the expected hash is trusted, and a signature must be verified before the archive is accepted as publisher-authored.

If manually constructing an unarchiver, enable requiresSecureCoding before decoding and keep that requirement enabled for the whole graph. Do not catch a secure-coding failure and retry the same bytes through an unrestricted legacy decoder. That pattern turns the strict path into a warning rather than a boundary. The top-level convenience APIs make the expected class and strict mode visible at the call site.

Evolve an archive format deliberately

The keys passed to encode(_:forKey:) and the matching decode calls are part of a persisted schema. Renaming a key can make a previously valid archive appear to have missing data. Keep keys stable, distinguish an absent optional value from an invalid value, and define defaults only where the product semantics permit them.

For a format that will survive application upgrades, encode an explicit schema version and write migration code that transforms old valid representations into the current model. Test archives from every supported version, including absent optional keys, unknown future versions, wrong root classes, and unexpected nested classes. A failed migration should preserve the original bytes for diagnosis or rollback instead of overwriting the only copy with an empty default object.

Do not equate the NSSecureCoding flag with a format-version change. Secure decoding constrains class acceptance; it does not provide a complete versioning strategy. An older archive may have been written without secure coding, so migration should be an intentional, bounded conversion path. Prefer a non-object interchange format such as a versioned property list or JSON for simple long-lived data when the object graph and class identity features of keyed archives are not needed.

Test both accepted and rejected graphs

Run the round-trip example on the macOS SDK that ships with the application. Then build negative fixtures: a wrong root class, a custom class not on the allowlist, a missing required key, a string above the product’s size limit, and each historical schema version. Assert that the strict unarchiver rejects a disallowed class and that application validation rejects an allowed but semantically invalid value.

Record the archive format version and the decoder policy in diagnostics, but never log private field contents. Test restoration from a copy of real prior-version archives in an isolated test directory. Avoid silently “repairing” a failure by trying progressively broader class sets, because that makes a corrupted or hostile archive control what the process will instantiate.

NSSecureCoding is useful when an app needs to archive Foundation or custom object graphs without granting the archive unrestricted class-instantiation power. A clear top-level class, narrow nested allowlists, validated values, and explicit migrations make the security property reviewable. They do not remove the need to authenticate, authorize, size-bound, and version the data according to where it came from and what the application will do with it.

Related:

Sources:

Comments