Skip to main content

Access Control Patterns

Confidentiality does not replace access control. It is a separate layer that hides the state from unauthorized readers of the chain — but inside the contract, you still have to decide which callers can read and write what.

Fortunately, the standard EVM tools you already know work identically on OXN. This page catalogs the patterns.

msg.sender is trustworthy

msg.sender inside a state-changing transaction is the transaction signer, verified by the chain layer. Nobody can spoof it. This is exactly the Ethereum guarantee, and OXN preserves it.

Inside a view function called via a signed query, msg.sender is the address that signed the query. Nobody can spoof this either. See Signed Queries.

Inside a view function called via plain or unauthenticated eth_call, msg.sender is 0x0. Do not rely on it for authorization.

Pattern: owner-only writes

Standard Solidity. Nothing OXN-specific.

contract Vault {
address public owner = msg.sender;
mapping(address => uint256) private balances;

modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}

function setBalance(address who, uint256 amount) external onlyOwner {
balances[who] = amount;
}
}

Because state-changing transactions must be signed and msg.sender is verified, this pattern works on OXN exactly as on Ethereum.

Pattern: per-user private reads

Users can only read their own state. This is the flagship pattern for confidential ERC-20 balances, private orders, private votes, etc.

contract PrivateToken {
mapping(address => uint256) private balances;

function myBalance() external view returns (uint256) {
return balances[msg.sender];
}

function balanceOf(address who) external view returns (uint256) {
require(msg.sender == who, "not authorized");
return balances[who];
}
}

The caller must issue this as a signed query for msg.sender to be populated. See Signed Queries.

Pattern: delegated reads (approvals)

The contract owner approves specific readers who can also see certain state.

mapping(address => uint256) private balances;
mapping(address => mapping(address => bool)) private readApprovals;

function approveReader(address reader) external {
readApprovals[msg.sender][reader] = true;
}

function readBalance(address owner) external view returns (uint256) {
require(msg.sender == owner || readApprovals[owner][msg.sender], "no approval");
return balances[owner];
}

Both branches — self-read and approved-read — require a signed query.

Pattern: role-based access with OpenZeppelin

OpenZeppelin's AccessControl works unchanged. Combine it with private storage for confidential role-gated data:

import "@openzeppelin/contracts/access/AccessControl.sol";

contract AuditableSecret is AccessControl {
bytes32 public constant AUDITOR_ROLE = keccak256("AUDITOR_ROLE");
string private secretData;

constructor(address admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
}

function readSecret() external view returns (string memory) {
require(hasRole(AUDITOR_ROLE, msg.sender), "not auditor");
return secretData;
}
}

Auditors read via signed query; nobody else can decrypt the result.

Pattern: time-locked reveal

State stored encrypted, revealed only after a deadline. Common for sealed-bid auctions.

struct Bid {
uint256 amount;
bool revealed;
}
mapping(address => Bid) private bids;
uint256 public revealDeadline;

function submitBid(uint256 amount) external {
require(block.timestamp < revealDeadline, "bidding closed");
bids[msg.sender] = Bid(amount, false);
}

function reveal() external view returns (uint256) {
require(block.timestamp >= revealDeadline, "not yet");
return bids[msg.sender].amount;
}

function winner() external view returns (address, uint256) {
require(block.timestamp >= revealDeadline, "not yet");
// iterate bids, return winner
// ...
}

Before revealDeadline, no caller can extract their own bid because submitBid doesn't return it and reveal reverts. After the deadline, callers can read their own bid via signed query, and the contract can reveal the winner publicly.

Pattern: encrypted per-user secrets

Storing arbitrary user secrets — session tokens, API keys, cryptographic material.

mapping(address => bytes) private secrets;

function storeSecret(bytes calldata secret) external {
secrets[msg.sender] = secret;
}

function getSecret() external view returns (bytes memory) {
return secrets[msg.sender];
}

The storeSecret call must be an encrypted transaction (secret is in calldata). The getSecret call must be a signed query. Together, the secret goes into storage encrypted, sits there encrypted, and comes out only to its owner.

What does NOT work as a substitute

Do not use tx.origin for authorization. Same reasons as on Ethereum — vulnerable to phishing contracts. Confidentiality does not fix this.

Do not use auto-generated public getters for private state. The public keyword generates a plain unauthenticated getter. See Encrypted Storage for the safe alternative.

Do not implement "any signature works" access control. Access control must check which address signed, not just that some signature exists.

Next steps