DEV Community

llzzz
llzzz

Posted on

Deep dive into the C++ Passkey idiom

1. Motivation: The Problem with friend

In C++, friend is a double-edged sword. If you allow your friend to touch you inappropriately, that would be hurt. If class A befriends another class B, then everything in A becomes visible to B. friend grants all-or-nothing access — which is rarely what you actually want. You usually want your friend to only able to touch a portion of you.

Consider the following scenario. You have a Widget class that must be created through a factory, because the constructor needs to perform some privileged setup (registering with a resource manager, validating invariants, logging, and so on). But the constructor itself has to be public for std::make_unique or for the factory to reach it. The moment it is public, anyone can construct a Widget — and that is a correctness bug waiting to happen.

You might reach for friend:


class WidgetFactory;

class Widget {
    friend class WidgetFactory;   // grants access to EVERYTHING
public:
    Widget(int x) : x_{x} {}
private:
    int x_{};
};

class WidgetFactory {
public:
    static std::unique_ptr<Widget> create(int x) {
        return std::make_unique<Widget>(x);
    }
};

int main()
{
    auto w = WidgetFactory::create(42);   // ok
    // auto bad = std::make_unique<Widget>(42); // also ok! constructor is public, but you dont want this...
}
Enter fullscreen mode Exit fullscreen mode

This "works," but it is a blunt instrument. WidgetFactory now has access to every private member of Widget, not just the constructor. As Widget grows, so does the accidental attack surface. And if you later need to grant the same narrow access to a Debugger class, you add another friend, and the surface widens again.

The question this idiom answers is:

How can I let a specific class touch only a specific portion of my class — and nothing else?

And then now let me introduce the PassKey Idiom.


2. The Classic Solution: A Nested PassKey

The core idea is to make a public function that requires an argument nobody else can construct. That argument is the passkey.

class Widget {
public:
    class PassKey {
        friend class Widget;
        PassKey() = default; // this constructor is private
    };

    Widget(PassKey, int x)
        : x_{x}
    {}

    static std::unique_ptr<Widget> create(int x)
    {
        return std::make_unique<Widget>(PassKey{}, x);
    }

private:
    int x_{};
};

int main()
{
    // auto w1 = Widget{Widget::PassKey{}, 42}; // compile error! cannot access private constructor
    auto w2 = Widget::create(42); // ok
}
Enter fullscreen mode Exit fullscreen mode

A few things are worth calling out:

  • The constructor PassKey() = default; is implicitly private (it is a member of a class whose default access is private), and Widget is its only friend.
  • Widget's constructor is public, so it satisfies the syntactic requirements of std::make_unique. But it is only callable by code that can produce a PassKey — which, in practice, means only Widget itself (or whatever Widget chooses to befriend).
  • In main, the direct construction fails to compile because Widget::PassKey is private. The only way in is through create.

This is a clean, zero-boilerplate solution for a single class. But it does not scale. If you have Widget, Gadget, and Sprocket, you must replicate this nested-class boilerplate in every one of them. Worse, the authorized "friend" is baked into the nested definition, so if you want Widget's constructor callable by both a Factory and a Debugger, you either befriend both inside PassKey or duplicate the pattern. How to solve this? we can use a template!


3. Scaling Up: The Templated PassKey

The templated PassKey decouples the mechanism from the class it protects. You define it once, and any class can use it:

template <typename T>
class Passkey {
    friend T;             // only T can construct a Passkey<T>
    Passkey() = default;  // private: no one else can create one
};
Enter fullscreen mode Exit fullscreen mode

Now the authorized class can create a passkey, and unauthorized code cannot:

class WidgetFactory;
class WidgetDebugger;

class Widget {
public:
    // Public constructor, but only callable by whoever can make a Passkey<WidgetFactory>
    Widget(Passkey<WidgetFactory>, int x)
        : x_{x}
    {}

    // A separate, narrower door for the debugger
    void dump(Passkey<WidgetDebugger>) const { /* ... */ }

private:
    int x_{};
};

class WidgetFactory {
public:
    static std::unique_ptr<Widget> create(int x) {
        return std::make_unique<Widget>(Passkey<WidgetFactory>{}, x);
    }
};

class WidgetDebugger {
public:
    void inspect(const Widget& w) {
        w.dump(Passkey<WidgetDebugger>{});
    }
};

int main()
{
    auto w = WidgetFactory::create(42);   // ok
    WidgetDebugger{}.inspect(*w);          // ok

    // auto bad = std::make_unique<Widget>(Passkey<WidgetFactory>{}, 1); // error
    // WidgetDebugger{}.inspect(...) can't call Widget's ctor; Factory can't call dump()
}
Enter fullscreen mode Exit fullscreen mode

The design now has two key properties:

  1. Per-function granularity. Widget's constructor admits only WidgetFactory. Widget::dump admits only WidgetDebugger. Neither can use the other's door. No friend grants blanket access.
  2. Reusable mechanism. Passkey<T> is written once and works for any number of classes. There is no nested-class boilerplate to copy.

A subtle but important point: std::make_unique cannot construct the Passkey for you. The authorized class must explicitly pass Passkey<WidgetFactory>{} into the constructor call. That is by design — make_unique is not the privileged party; the caller is.

Improvement you can made if you are using C++20

C++20 does not fundamentally change the PassKey mechanism. There is no concept that replaces it, because concepts constrain types, whereas PassKey constrains who can produce a token. The two compose nicely, though: if you are building a generic system where the set of authorized callers is itself parameterized, you can write

#include <type_traits>

// Marker: only types deriving from this may be a Caller.
struct Privileged {};

// Passkey<T> only exists if T is privileged.
template <typename T>
    requires std::is_base_of_v<Privileged, T>
class Passkey {
    friend T;
    Passkey() = default;
};

class Widget {
public:
    Widget(Passkey<class Factory>, int x) : x_{x} {}
private:
    int x_{};
};

// Factory opts in — so it can make a Passkey<Factory>.
class Factory : Privileged {
public:
    static Widget create(int x) { return Widget(Passkey<Factory>{}, x); }
};

// Sneaky does NOT opt in — so Passkey<Sneaky> won't compile.
class Sneaky {
public:
    // Widget w{Passkey<Sneaky>{}, 1};  // error: constraint not satisfied
};
Enter fullscreen mode Exit fullscreen mode

That's the whole idea. Widget's constructor is public, but the only way to call it is to hand it a Passkey<Factory> — and only Factory can construct one, because Factory is the friend. The requires clause adds one extra guarantee: you can't even write Passkey<X> unless X derives from Privileged, so unauthorized types fail at the template level instead of at the friend level.


4. Group Permissions: Variadic Friends (C++26)

The templated PassKey has one remaining friction point. Suppose a single function should be callable by several authorized classes — say a Factory, a Debugger, and a Serializer. Before C++26, you cannot write friend Ts...; expanding a parameter pack in a friend declaration is ill-formed. The workarounds are ugly: manual overloads, a combined passkey type, or a chain of nested friends.

C++26 fixes this with variadic friends (P2893R3). You can now expand a pack directly:

#include <iostream>

// C++26: variadic friends — one passkey authorizes a whole pack of types.
template <typename... Ts>
class Passkey {
    friend Ts...;
    Passkey() = default;
};

class Factory;
class Debugger;
class Serializer;

class Widget {
public:
    // Only Factory, Debugger, and Serializer can call this.
    void secretInit(Passkey<Factory, Debugger, Serializer>) {
        std::cout << "Widget initialized\n";
    }
};

// Each authorized type can build the passkey — and thus call secretInit.
class Factory {
public:
    void make(Widget& w) { w.secretInit(Passkey<Factory, Debugger, Serializer>{}); }
};

class Debugger {
public:
    void poke(Widget& w) { w.secretInit(Passkey<Factory, Debugger, Serializer>{}); }
};

class Serializer {
public:
    void save(Widget& w) { w.secretInit(Passkey<Factory, Debugger, Serializer>{}); }
};

// Not in the pack — cannot construct the passkey.
class Intruder {
public:
    // void tryIt(Widget& w) { w.secretInit(Passkey<Factory, Debugger, Serializer>{}); }
    //   ^ error: Passkey ctor is private; Intruder is not a friend.
};

int main()
{
    Widget w;
    Factory{}.make(w);        // ok
    Debugger{}.poke(w);       // ok
    Serializer{}.save(w);     // ok
}
Enter fullscreen mode Exit fullscreen mode

This makes the "group permission" case a one-liner and removes the need for the combined-type hack. It is a small language feature, but for the PassKey idiom it is a meaningful ergonomic win: the passkey now expresses "these N types, and no others" directly in the type system.


5. Performance and Trade-offs

Runtime cost

In theory, passing an empty object by value could cost a register or a stack slot. In practice, in optimized builds, it is a zero-cost abstraction. Because Passkey has no data members and the parameter is unnamed, the optimizer elides it entirely at -O1 and above. There is no runtime check, no branch, no token comparison — the entire mechanism lives in the type system and disappears in the emitted code.

The one caveat

If the protected function is not inlined (defined in a separate translation unit, and LTO is disabled), the calling convention may still require passing a byte for the empty class to satisfy the ABI. This is a minor, usually irrelevant cost. For header-only or inlined usage — which is the common case — there is no overhead at all.

Compile-time cost

Slightly more template instantiation (one specialization per authorized type), and slightly longer function signatures. This is the real price, but it is usually trivial.

Readability trade-offs

  • Signatures get noisier: Widget(Passkey<Factory>, int) is visually heavier than Widget(int).
  • The unnamed parameter convention (see below) is what keeps it readable; without it, reviewers wonder what the argument is for.
  • The mechanism is compile-time enforced, so the compiler error when someone tries to bypass it is clear — but the reason the parameter exists is not obvious to a reader seeing the class for the first time. A brief comment on the constructor is worth the cost.

6. Idiomatic Usage and Best Practices

  • Leave the parameter unnamed. Write void foo(Passkey<T>), not void foo(Passkey<T> key). The unnamed parameter signals "this is a token, not data — it carries no information and is never used in the body."
  • Mark the constructor explicit if you are being defensive. explicit Passkey() = default; is not strictly required — the private access already blocks unauthorized construction — but it guards against brace-initialization surprises and makes intent explicit. It is harmless and recommended for older-standard compatibility (C++17 or earlier).
  • Prefer the templated form. The nested form is fine as a teaching example, but the templated form is the one to use in production.
  • Document the contract. A one-line comment — // only Factory may construct Widget — saves future readers from reverse-engineering the idiom.

8. Summary

  • friend is all-or-nothing; the PassKey idiom gives you per-function, per-caller access control.
  • The nested PassKey is the minimal teaching form; the templated Passkey<T> is the idiomatic production form.
  • C++26 variadic friends (friend Ts...) turns "group permissions" into a one-liner, removing the last piece of friction.
  • It is a zero-cost abstraction in optimized builds; the cost is compile-time and readability, not runtime.
  • Use it for restricted constructors and sensitive methods; use Attorney-Client when you need to expose members instead.

Happy coding!

Top comments (0)