Skip to main content
Security warning — ownPublicKey() does not authorize the caller.ownPublicKey() returns a value the prover knows, not a proof of key ownership. Any value stored on the public ledger can be replayed by anyone reading the chain. Do not use ownPublicKey() for authorization checks (assert(ownPublicKey() == storedAdmin)). Use it only as an identifier for the operation’s target (e.g. “mint to this caller”). For authorization, see Owner-Only Pattern below.

The Pattern

pragma language_version >= 0.22.0;

import CompactStandardLibrary;

struct AdminSecretKey { bytes: Bytes<32>; }
struct AdminPublicKey { bytes: Bytes<32>; }

// The ledger stores only the derived public key (a hash of the secret),
// never the secret itself.
export ledger contractAdmin: AdminPublicKey;

// The deployer's DApp generates this secret and keeps it in private state.
witness getAdminSecret(): AdminSecretKey;

constructor() {
    contractAdmin = disclose(deriveAdminPublicKey(getAdminSecret()));
}

export circuit deriveAdminPublicKey(sk: AdminSecretKey): AdminPublicKey {
    return AdminPublicKey {
        bytes: persistentHash<Vector<2, Bytes<32>>>([
            pad(32, "myapp:admin:pk:v1"),
            sk.bytes
        ])
    };
}

export circuit privilegedAction(): [] {
    // To pass, the caller must supply a witness `sk` such that
    // H("myapp:admin:pk:v1" || sk) == contractAdmin.
    assert(
        contractAdmin == deriveAdminPublicKey(getAdminSecret()),
        "Not authorized."
    );
    // ... privileged logic
}

// Rotate the admin key without ever transmitting a private key.
// The new admin generates their secret locally and shares only the
// derived public key with the current admin.
export circuit rotateAdmin(newAdmin: AdminPublicKey): [] {
    assert(
        contractAdmin == deriveAdminPublicKey(getAdminSecret()),
        "Not authorized to rotate admin."
    );
    contractAdmin = disclose(newAdmin);
}

Owner-Only Pattern

The contract stores H(domain || secret) on the ledger, never the secret itself. The admin’s DApp holds the secret in private state and re-derives the public key on every call. The on-chain value gives an attacker only the hash; hash preimage resistance means they cannot forge a matching secret.

Store the Admin Public Key

struct AdminSecretKey { bytes: Bytes<32>; }
struct AdminPublicKey { bytes: Bytes<32>; }

export ledger contractAdmin: AdminPublicKey;

witness getAdminSecret(): AdminSecretKey;

constructor() {
    contractAdmin = disclose(deriveAdminPublicKey(getAdminSecret()));
}

export circuit deriveAdminPublicKey(sk: AdminSecretKey): AdminPublicKey {
    return AdminPublicKey {
        bytes: persistentHash<Vector<2, Bytes<32>>>([
            pad(32, "myapp:admin:pk:v1"),
            sk.bytes
        ])
    };
}
The domain string "myapp:admin:pk:v1" binds the public key to this contract. The same secret will not derive the same public key under a different domain, so an admin key cannot be replayed across contracts.

Authorization Check

export circuit privilegedAction(): [] {
    assert(
        contractAdmin == deriveAdminPublicKey(getAdminSecret()),
        "Not authorized."
    );
    // ... privileged logic
}
The assertion succeeds only if the caller knows a secret whose hash matches the stored value. Reading contractAdmin off-chain gives an attacker the hash, not the preimage.

Rotate the Admin

export circuit rotateAdmin(newAdmin: AdminPublicKey): [] {
    assert(
        contractAdmin == deriveAdminPublicKey(getAdminSecret()),
        "Not authorized to rotate admin."
    );
    contractAdmin = disclose(newAdmin);
}
The new admin generates their secret locally and shares only the derived public key with the current admin. Private keys never leave the holder’s DApp.

Why This Works

The ledger stores H(domain || secret) — the hash of the admin’s secret. To pass the assertion, the caller must provide a witness sk such that H(domain || sk) equals the stored value. The on-chain value is a hash, not the secret itself, so reading the ledger gives an attacker nothing they can replay. The domain string prevents the same secret from being valid across different contracts. This is what Solidity gets implicitly from msg.sender and EVM signature verification. In Compact, you construct the check yourself.

Anti-Pattern: ownPublicKey() as Authorization

Do not use this pattern. It is shown only to document what fails.
// DO NOT USE — this is broken
export ledger admin: ZswapCoinPublicKey;

constructor() {
    admin = disclose(ownPublicKey());
}

export circuit privileged(): [] {
    assert(ownPublicKey() == admin, "Only admin.");
}
ownPublicKey() returns whatever value the prover claims to know. An attacker reads admin from the public ledger, supplies it as their own ownPublicKey(), and produces a mathematically valid proof. The proof correctly demonstrates “the prover knows a value equal to admin” — which anyone watching the chain does. The check provides no authorization.

When to Use ownPublicKey()

ownPublicKey() is the correct primitive when you need a stable identifier for the caller as the target of an operation:
  • “Mint a token to this caller”: mint(ownPublicKey(), tokenId)
  • “Credit this caller’s balance”: balances.insert(ownPublicKey(), ...)
  • “Record this caller as the recipient”: recipient = disclose(ownPublicKey())
It identifies who the operation is for. It does not prove who is allowed to perform it. The moment you write assert(ownPublicKey() == ...), you have crossed into authorization, and the pattern breaks.

Role-Based Access

Multiple roles use the same keypair pattern with one ledger slot per role and a single witness that returns the caller’s secret.
struct SecretKey { bytes: Bytes<32>; }
struct PublicKey { bytes: Bytes<32>; }

export ledger contractOwner: PublicKey;
export ledger contractMinter: PublicKey;

witness getCallerSecret(): SecretKey;

constructor(initialMinter: PublicKey) {
    contractOwner = disclose(derivePublicKey(getCallerSecret()));
    contractMinter = disclose(initialMinter);
}

export circuit derivePublicKey(sk: SecretKey): PublicKey {
    return PublicKey {
        bytes: persistentHash<Vector<2, Bytes<32>>>([
            pad(32, "myapp:role:v1"),
            sk.bytes
        ])
    };
}

// Owner-only: rotate the minter slot
export circuit setMinter(newMinter: PublicKey): [] {
    assert(
        contractOwner == derivePublicKey(getCallerSecret()),
        "Only owner can set minter."
    );
    contractMinter = disclose(newMinter);
}

// Either role may call this
export circuit mintingAction(): [] {
    const callerKey = disclose(derivePublicKey(getCallerSecret()));
    assert(
        callerKey == contractOwner || callerKey == contractMinter,
        "Not authorized to mint."
    );
    // ... minting logic
}
The || check would reveal which role matched, so callerKey is disclosed explicitly. That is safe: a derived public key equals the on-chain value only when the caller is already authorized, and the on-chain value is public anyway.

Alternative Patterns

The keypair pattern above covers single-admin and role-based authorization. For other situations:
  • Signature verification against a stored verifying key — when you need multi-sig, cold-key signing, or cross-contract authority. The contract stores a verifying key; privileged circuits accept a signature over the operation as a parameter.
  • Commitment / nullifier patterns — when you need anonymous user authorization with unlinkability across actions (anonymous voting, single-use credentials). The contract stores commitments and tracks nullifiers.
Both are documented elsewhere.

What’s Next

Minting

Apply access control to minting

ERC20 Token

See access control in a complete token