Passkey Idiom
Grant selective access to private constructors and methods using an unforgeable token type, replacing coarse-grained friend declarations.
Passkey Idiomsince C++11A technique that restricts access to specific constructors or methods by requiring an unforgeable token type whose private constructor only the designated caller can invoke.
Overview
friend is binary: granting it gives a class access to every private member. This is rarely what you want. The passkey idiom threads the needle β it exposes one constructor or one method to one designated class, and nothing else.
The mechanism is a small token type templated on the privileged caller. Its constructor is private, with the caller named as a friend. Any method or constructor that should be privileged takes this token as a parameter. Since only the friend can construct the token, only the friend can supply the argument β the compiler enforces the restriction at the call site.
C++11 introduced first-class support for friend T where T is a type template parameter (previously ill-formed). The entire idiom depends on that language rule.
The token itself is an empty class. The compiler elides it entirely; there is zero runtime overhead.
Syntax
// C++11: friend T where T is a template parameter
template<typename T>
class Passkey {
friend T; // only T can construct this
Passkey() {} // user-provided body β critical for C++11β17 safety (see Pitfalls)
Passkey(const Passkey&) = delete;
Passkey& operator=(const Passkey&) = delete;
};A guarded constructor or method simply adds Passkey<Caller> to its parameter list:
class Widget {
public:
explicit Widget(int x, Passkey<Factory>); // only Factory can call this
};The caller constructs the token inline at the call site β no named variable needed, no storage:
class Factory {
public:
Widget make(int x) {
return Widget{x, Passkey<Factory>{}}; // constructs and passes immediately
}
};External code attempting Passkey<Factory>{} gets a compile error: the constructor is private.
Examples
Controlling Construction: Connection Pool
A Connection should only be created by ConnectionPool. A private constructor with a static create() would work if there is one factory; passkey scales to multiple authorized callers without exposing all private state.
class Connection {
public:
// Only ConnectionPool may construct a Connection directly
Connection(std::string host, int port, Passkey<ConnectionPool>)
: host_{std::move(host)}, port_{port} {}
// Public interface β anyone can use an existing connection
void execute(std::string_view sql);
bool is_alive() const;
private:
std::string host_;
int port_;
// internal state hidden from ConnectionPool
void* impl_ = nullptr;
};
class ConnectionPool {
std::vector<Connection> pool_;
public:
void grow(std::string host, int port) {
pool_.emplace_back(std::move(host), port, Passkey<ConnectionPool>{});
}
Connection& acquire();
void release(Connection&);
};
// Won't compile β Passkey<ConnectionPool>() is private:
// Connection c{"db.local", 5432, Passkey<ConnectionPool>{}};ConnectionPool cannot touch Connection::impl_ β it can only call the public interface like anyone else. friend would have given it everything.
Controlling Method Access: System Lifecycle
Some operations are valid in exactly one context. Passkey makes that context explicit in the type signature rather than burying it in a comment.
class EventBus {
public:
void subscribe(std::string event, std::function<void()> fn) {
handlers_[std::move(event)].push_back(std::move(fn));
}
void dispatch(std::string_view event);
// Clearing all state is a shutdown-only operation β only SystemCore may call this
void clear_all(Passkey<SystemCore>) {
handlers_.clear();
}
private:
std::unordered_map<std::string,
std::vector<std::function<void()>>> handlers_;
};
class SystemCore {
EventBus& bus_;
public:
explicit SystemCore(EventBus& b) : bus_{b} {}
void shutdown() {
// other teardown ...
bus_.clear_all(Passkey<SystemCore>{});
}
};The guarded parameter appears in the documentation, autocomplete, and error messages. Reviewers can see the intent without reading comments.
Multiple Distinct Callers
When two unrelated classes need access to different parts of a type, give each its own passkey parameter on a separate overload or a separate method:
class Buffer {
public:
// Loader fills the buffer during asset loading
void fill(std::span<const std::byte> data, Passkey<Loader>);
// Renderer reads raw bytes for upload to GPU
const std::byte* raw(Passkey<Renderer>) const;
// Public interface for everyone else
std::size_t size() const;
bool empty() const;
private:
std::vector<std::byte> data_;
};Each caller's access is confined to exactly the operation it needs. Neither Loader nor Renderer is a friend, so neither can touch data_ directly.
Multiple Independent Guards on One Class
Sometimes a class needs to restrict two completely separate entry points to two completely separate callers. Each gets its own distinct passkey β the holder of one cannot use the other.
// C++11
class Document {
public:
// Only DocumentParser may construct a Document
Document(std::string content, Passkey<DocumentParser>)
: content_{std::move(content)} {}
// Only DocumentSerializer may call clone() β e.g. for deep-copy archival
Document clone(Passkey<DocumentSerializer>) const {
return Document{content_, Passkey<DocumentParser>{}};
// ^ wait β DocumentSerializer can't construct a Passkey<DocumentParser>!
}
private:
std::string content_;
};The clone implementation above is flawed: DocumentSerializer cannot call the constructor because it holds Passkey<DocumentSerializer>, not Passkey<DocumentParser>. This is intentional β the two callers are independent. The fix is to give clone a separate internal helper, or allow the constructor to accept both tokens via overloads:
// C++11: two independent entry points, two independent guards
class Document {
public:
// Construction reserved for DocumentParser
Document(std::string content, Passkey<DocumentParser>)
: content_{std::move(content)} {}
// Clone reserved for DocumentSerializer; returns by value
// DocumentSerializer cannot construct a DocumentParser passkey β
// these are genuinely independent restrictions
[[nodiscard]] Document clone(Passkey<DocumentSerializer>) const;
private:
std::string content_;
// Private helper used only by clone() β no passkey needed here
Document clone_impl() const { return Document{content_, internal_tag{}}; }
struct internal_tag {};
explicit Document(std::string content, internal_tag)
: content_{std::move(content)} {}
};
Document Document::clone(Passkey<DocumentSerializer>) const {
return clone_impl();
}
// Call sites:
class DocumentParser {
public:
Document parse(std::string_view text) {
return Document{std::string{text}, Passkey<DocumentParser>{}};
}
};
class DocumentSerializer {
public:
Document archive_copy(const Document& d) {
return d.clone(Passkey<DocumentSerializer>{});
}
};
// Neither caller can use the other's privilege:
// DocumentParser p;
// p.clone(Passkey<DocumentSerializer>{}); // error: Passkey<DocumentSerializer> is privateThe internal struct internal_tag is a private type visible only to Document's own methods β a lightweight second idiom used here to break the circular dependency between the two passkey-guarded operations.
Factory Pattern Integration
The passkey idiom and the factory pattern compose naturally. The canonical goal is to prevent direct new Widget(...) while keeping Widget allocatable by std::make_shared inside a factory.
Private constructor + friend vs. Passkey
These two approaches achieve similar goals but differ in scope:
// Approach A: private constructor + friend Factory
// C++11
class Widget_A {
friend class Factory_A;
explicit Widget_A(int x) : x_{x} {} // completely private
int x_;
public:
int value() const { return x_; }
};
class Factory_A {
public:
static Widget_A make(int x) { return Widget_A{x}; }
// Factory_A can also access x_ directly β may be undesired
};
// Approach B: public constructor + Passkey<Factory>
// C++11
class Widget_B {
public:
explicit Widget_B(int x, Passkey<Factory_B>) : x_{x} {}
int value() const { return x_; }
private:
int x_;
};
class Factory_B {
public:
static Widget_B make(int x) {
return Widget_B{x, Passkey<Factory_B>{}};
}
// Factory_B cannot touch x_ β only the specific constructor is accessible
};Widget_A with friend Factory_A gives the factory full access to all private members β including x_. Widget_B with Passkey<Factory_B> grants only the constructor; x_ remains off-limits to Factory_B.
std::make_shared and the Passkey constructor
std::make_shared<Widget>(args...) calls the constructor directly. If the constructor is private (Approach A), make_shared cannot reach it β even if the factory is a friend β because std::make_shared's internal allocation helper is not a friend. Approach B solves this cleanly: the constructor is public, guarded only by the passkey argument.
// C++11
class Widget {
public:
// Constructor is public but unforgeable without Factory's token
explicit Widget(int x, Passkey<Factory>) : x_{x} {}
int value() const { return x_; }
private:
int x_;
};
class Factory {
public:
// make_shared can call a public constructor β passkey prevents everyone else
static std::shared_ptr<Widget> create(int x) {
return std::make_shared<Widget>(x, Passkey<Factory>{});
}
};
// make_shared works because the constructor is public:
// std::make_shared<Widget> does not need to be a friend.
// Direct construction still blocked:
// auto w = std::make_shared<Widget>(42, Passkey<Factory>{});
// ^ error: Passkey<Factory>() is privateThis approach also sidesteps the common enable_shared_from_this pitfall. When Widget derives from std::enable_shared_from_this<Widget>, calling shared_from_this() requires that the object was originally created via a shared_ptr. If the constructor is private and only reachable through a new-based path followed by manual shared_ptr wrapping, it is easy to forget this constraint. The passkey pattern with make_shared enforces the correct construction path at compile time.
// C++11: enable_shared_from_this integration
class Node : public std::enable_shared_from_this<Node> {
public:
explicit Node(int val, Passkey<NodeFactory>) : val_{val} {}
// safe: Node was always created via make_shared
std::shared_ptr<Node> self() { return shared_from_this(); }
int value() const { return val_; }
private:
int val_;
};
class NodeFactory {
public:
static std::shared_ptr<Node> create(int val) {
return std::make_shared<Node>(val, Passkey<NodeFactory>{});
}
};Because NodeFactory::create always uses make_shared, shared_from_this() will never throw std::bad_weak_ptr. The passkey makes this invariant structurally impossible to violate.
Passkey vs. friend: Comparison
The choice between friend, passkey, and a plain public function depends on the scope of access required. Neither is universally superior β the right tool depends on how much access the caller actually needs.
| Mechanism | Who can call | What they can access | Compile-time checked |
|---|---|---|---|
public method/ctor | Everyone | That one entry point | Yes |
friend class F | Class F only | All private members | Yes |
Passkey<F> on one method | Class F only | That one entry point | Yes |
Passkey<F> on N methods | Class F only | Those N entry points | Yes |
The practical decision tree:
- Use
publicwhen the operation is genuinely unrestricted. No access-control complexity needed. - Use
friendwhen a tightly coupled class needs access to many or all private members β typically a helper class that is in the same translation unit and maintained in lockstep (e.g., an iterator over a container's internal structure). - Use passkey when one class needs access to one or two specific entry points but should not see the rest of the type's internals. Also use passkey when multiple unrelated classes each need access to different entry points β you cannot give each an independent
friendthat restricts to one member.
// C++11: when friend is appropriate β Iterator needs full Container internals
class Container {
friend class Iterator; // Iterator manipulates node pointers directly
struct Node { int val; Node* next = nullptr; };
Node* head_ = nullptr;
};
// C++11: when passkey is better β only one specific operation matters
class Registry {
public:
// Only RegistrationService may add entries; it must not see other internals
void register_entry(std::string key, Passkey<RegistrationService>);
std::string lookup(std::string_view key) const; // public
private:
std::unordered_map<std::string, std::string> entries_;
// internal audit log, metrics, etc. β RegistrationService must not touch these
};friend class RegistrationService would expose the audit log and metrics. Passkey<RegistrationService> exposes only register_entry.
Type-Parameterized Passkey with Concept Constraints
In large codebases with heavily templated code, a broadly instantiated template might accidentally supply a passkey for a type it doesn't "own". A constrained passkey closes this gap by requiring the privileged caller to satisfy a concept.
// C++20
#include <concepts>
// Concept: a type is an authorized factory if it has a nested is_factory tag
template<typename T>
concept AuthorizedFactory = requires {
typename T::is_factory_tag;
};
// Constrained passkey: only types satisfying AuthorizedFactory can be T
template<AuthorizedFactory T>
class FactoryPasskey {
friend T;
FactoryPasskey() {}
FactoryPasskey(const FactoryPasskey&) = delete;
FactoryPasskey& operator=(const FactoryPasskey&) = delete;
};
class Widget {
public:
explicit Widget(int x, FactoryPasskey<WidgetFactory>) : x_{x} {}
int value() const { return x_; }
private:
int x_;
};
class WidgetFactory {
public:
using is_factory_tag = void; // satisfies AuthorizedFactory concept
static Widget create(int x) {
return Widget{x, FactoryPasskey<WidgetFactory>{}};
}
};
// A generic helper template cannot accidentally forge a passkey:
template<typename F>
void attempt_create() {
// FactoryPasskey<F>{} β fails to compile if F doesn't have is_factory_tag
// The concept constraint rejects F at the template instantiation site
// with a clear diagnostic rather than a cryptic "constructor is private"
}The concept constraint shifts the error from "constructor is private in Passkey" (pointing inside the idiom machinery) to "constraint not satisfied for AuthorizedFactory" (pointing at the actual caller). This is especially useful when passkey misuse comes from deep template instantiation chains where the real culprit is several levels away from the error site.
A lighter-weight alternative for C++17 and earlier uses a static_assert inside the passkey body:
// C++17 alternative without concepts
template<typename T>
class FactoryPasskey17 {
friend T;
FactoryPasskey17() {
static_assert(std::is_base_of_v<FactoryBase, T>,
"T must derive from FactoryBase to use FactoryPasskey17");
}
FactoryPasskey17(const FactoryPasskey17&) = delete;
FactoryPasskey17& operator=(const FactoryPasskey17&) = delete;
};The static_assert fires at instantiation time with a human-readable message.
std::make_shared with Passkey
The interaction between make_shared and access control is a frequent source of confusion. This section examines the problem and the passkey solution in detail.
The problem with private constructors
When a constructor is private, std::make_shared cannot call it even if a friend declaration appears to authorize it:
// C++11: this does NOT compile β make_shared cannot reach a private ctor
class Thing {
friend class ThingFactory;
explicit Thing(int x) : x_{x} {} // private
int x_;
};
class ThingFactory {
public:
static std::shared_ptr<Thing> create(int x) {
return std::make_shared<Thing>(x); // error: Thing() is private
// std::make_shared uses an internal allocator helper that is not a friend
}
};The workaround without passkey requires either new (which loses the single-allocation benefit of make_shared) or a private tag type:
// C++11: private tag workaround β verbose and exposes the idiom internals
class Thing {
struct PrivateTag {};
public:
explicit Thing(int x, PrivateTag) : x_{x} {}
static std::shared_ptr<Thing> create(int x) {
return std::make_shared<Thing>(x, PrivateTag{});
}
private:
int x_;
};This is essentially the passkey idiom but with the tag scoped to a single class. Passkey makes it general and reusable.
The passkey solution
Passkey keeps the constructor public (so make_shared can see it) while making it impossible to call without the token:
// C++11: make_shared with passkey β preferred pattern
template<typename T>
class Passkey {
friend T;
Passkey() {}
Passkey(const Passkey&) = delete;
Passkey& operator=(const Passkey&) = delete;
};
class Session {
public:
// Public constructor β make_shared can call it
// But Passkey<SessionManager> is required β only SessionManager can supply it
Session(std::string id, int timeout_ms, Passkey<SessionManager>)
: id_{std::move(id)}, timeout_ms_{timeout_ms} {}
std::string_view id() const { return id_; }
bool is_expired() const;
private:
std::string id_;
int timeout_ms_;
// internal housekeeping state β hidden from SessionManager
std::chrono::steady_clock::time_point created_at_ =
std::chrono::steady_clock::now();
};
class SessionManager {
public:
std::shared_ptr<Session> open(std::string id, int timeout_ms = 30'000) {
// make_shared: single allocation for control block + Session object
auto s = std::make_shared<Session>(
std::move(id), timeout_ms, Passkey<SessionManager>{});
sessions_.push_back(s);
return s;
}
void gc(); // reap expired sessions from sessions_
private:
std::vector<std::weak_ptr<Session>> sessions_;
};The single allocation from make_shared (control block + Session in one heap block) is preserved. Direct construction remains blocked. SessionManager cannot access created_at_ or other private state.
Common Pitfalls
Aggregate Initialization Bypass (C++11β17)
This is the most dangerous defect. If Passkey uses = default instead of a user-provided constructor body, it is an aggregate in C++11 through C++17:
// UNSAFE in C++11, C++14, C++17:
template<typename T>
class Passkey {
friend T;
Passkey() = default; // not user-provided β class is an aggregate
};
// Aggregate initialization bypasses the private constructor:
Passkey<Factory> leak{}; // compiles β no restriction enforced
Widget w{leak}; // access control defeatedC++20 changed the aggregate definition to exclude classes with any user-declared constructor (even = default), closing this hole. For code targeting C++11β17, use a body:
Passkey() {} // user-provided body: class is never an aggregateThe safe canonical form that works correctly from C++11 onward:
// C++11: safe passkey β user-provided body, deleted copy/move
template<typename T>
class Passkey {
friend T;
Passkey() {} // user-provided: NOT an aggregate
Passkey(const Passkey&) = delete; // cannot be stashed or forwarded
Passkey& operator=(const Passkey&) = delete;
// No need to delete move: deleted copy suppresses move generation
};Deleting the copy constructor (and assignment) is equally important. Without it, code inside the privileged class could leak access by passing a stored Passkey object to an intermediary:
// If copy is not deleted, a privileged class could do this:
class Factory {
Passkey<Factory> stored_{}; // stash the key
public:
Passkey<Factory> borrow() { return stored_; } // compile error with deleted copy
};With the copy deleted, the token can only live as a temporary at the call site.
The C++20 fix for = default.
// C++20: = default is now safe due to new aggregate rules
template<typename T>
class Passkey {
friend T;
Passkey() = default; // C++20: user-declared β not an aggregate
Passkey(const Passkey&) = delete;
Passkey& operator=(const Passkey&) = delete;
};If your project's minimum standard is C++20, = default is acceptable. For portable code targeting C++11 and later, prefer the {} body β it is safe on all standards and the difference is purely cosmetic.
Wrong Template Argument
The compiler catches this, but the error message can be cryptic. If a function requires Passkey<Factory> and you pass Passkey<Widget>, you get a "constructor is private" diagnostic that points to Passkey's constructor rather than the call site. Keep template arguments close together in source to avoid confusion.
Leaking Through Forwarding Functions
A helper that forwards a passkey extends access beyond the intended owner:
// Dangerous: anyone can call make_widget and get Factory-level access
Widget make_widget(int x) {
return Widget{x, Passkey<Factory>{}}; // only safe inside Factory
}Passkey-constructing code belongs inside the privileged class, not in free functions or utilities that anyone can call.
Storing a Passkey in a Member Variable
Even without copying, storing the passkey in a member variable extends its lifetime and widens the access window:
// Antipattern: extending the lifetime of a passkey
class BadFactory {
// If copy weren't deleted, this would allow indefinite access:
// Passkey<BadFactory> key_{}; // construction works; later misuse possible
public:
Widget make(int x) {
// Correct: construct inline, never stored
return Widget{x, Passkey<BadFactory>{}};
}
};With Passkey(const Passkey&) = delete, the compiler prevents this. The rule reinforces the intended usage: the token is a call-site construct, not a durable capability.
Thread-Safe Singleton Using Passkey
A singleton must ensure that exactly one instance is constructed, and that construction is guarded. Passkey can express this invariant in the type system: the Instance() method is public (returns a reference), but construction is gated by a passkey that only the initialization path can supply.
// C++11: thread-safe singleton with passkey-guarded construction
#include <mutex>
class SingletonInit; // forward declaration of the init key holder
class AppConfig {
public:
// Public constructor β required for make operations, but unforgeable
// without SingletonInit's token
AppConfig(std::string config_path, Passkey<SingletonInit>)
: config_path_{std::move(config_path)} {
load();
}
// Standard singleton accessor β everyone can call this
static AppConfig& instance();
std::string_view get(std::string_view key) const;
private:
void load();
std::string config_path_;
std::unordered_map<std::string, std::string> values_;
static AppConfig* instance_;
static std::mutex init_mutex_;
};
// The sole class that may trigger construction
class SingletonInit {
public:
// Must be called once before any call to AppConfig::instance()
static void initialize(std::string config_path) {
std::lock_guard lock{AppConfig::init_mutex_};
if (!AppConfig::instance_) {
// Static local ensures single construction; lock guards the check
static AppConfig the_instance{
std::move(config_path), Passkey<SingletonInit>{}};
AppConfig::instance_ = &the_instance;
}
}
};
AppConfig& AppConfig::instance() {
std::lock_guard lock{init_mutex_};
if (!instance_) {
throw std::logic_error{"AppConfig not initialized β call SingletonInit::initialize first"};
}
return *instance_;
}A simpler and more idiomatic approach uses a static local variable (Meyers singleton) for guaranteed single initialization without an explicit lock:
// C++11: Meyers singleton with passkey β static local is initialized once
class AppConfig {
public:
AppConfig(std::string config_path, Passkey<AppConfig>)
: config_path_{std::move(config_path)} { load(); }
static AppConfig& instance(std::string config_path = {}) {
// Static local: constructed exactly once; thread-safe since C++11
static AppConfig inst{std::move(config_path), Passkey<AppConfig>{}};
return inst;
}
std::string_view get(std::string_view key) const;
private:
void load();
std::string config_path_;
std::unordered_map<std::string, std::string> values_;
};
// First call initializes; subsequent calls ignore the argument
AppConfig& cfg = AppConfig::instance("/etc/app/config.toml");Here AppConfig is its own privileged caller (Passkey<AppConfig>). The constructor is public so it is reachable from the static local initialization, but only code inside AppConfig can construct the passkey. Direct construction from outside is still blocked.
The C++11 standard guarantees that initialization of a function-local static is performed exactly once, even in the presence of concurrent callers β so no manual mutex is needed for the static initialization itself.
Best Practices
Use a user-provided constructor body, not = default. See the Aggregate Initialization Bypass pitfall for why = default is unsafe in C++11β17.
Delete copy and move. The token is a single-use key. Deleting copy prevents someone who receives a passkey argument from stashing it for later reuse, which would extend access beyond the intended scope.
template<typename T>
class Passkey {
friend T;
Passkey() {}
Passkey(const Passkey&) = delete;
Passkey& operator=(const Passkey&) = delete;
};Put the passkey last. Placing Passkey<Owner> as the final parameter keeps it visually separated from the meaningful arguments and makes call sites self-documenting: Widget{width, height, Passkey<Factory>{}}.
Keep the token local. Construct Passkey<T>{} inline at the call site. Never store it in a variable or pass it through intermediate functions β that widens the access surface.
Prefer passkey over friend when: the privileged caller needs access to one or two specific entry points, multiple unrelated classes need access to different entry points, or the type has other private state that should remain hidden.
Prefer passkey with make_shared. When a class is managed by shared_ptr, make the constructor public with a passkey guard rather than private with a friend. This allows std::make_shared to perform the single-allocation optimization while maintaining construction restrictions.
Use concept constraints (C++20) for large codebases. When heavily templated code might accidentally forge a passkey, add a concept constraint to the passkey template parameter to surface the error at the concept check rather than deep inside the idiom.
See Also
- CRTP β another template-based access-control technique, often combined with passkey for policy hierarchies
- Factory Idiom β when a single factory owns construction and
friendor private ctor +static create()is sufficient - PIMPL Idiom β hides implementation details entirely rather than selectively exposing them
- Singleton β the pattern that passkey can be used to guard construction for