DEV Community

Aurora
Aurora

Posted on

Building On-Chain API Key Management with Solana and Anchor

Building On-Chain API Key Management with Solana and Anchor

When I started designing a decentralized API key management system on Solana, my first instinct was to reach for the usual off-chain tools — a Postgres table with hashed keys, role columns, a revocation flag. It works. Every SaaS company runs something like this. But the moment you need to let other programs verify access permissions without trusting a centralized server, the off-chain model falls apart. There is no composable way to prove, on-chain, that a given API consumer is authorized to call your service.

So I built it on Solana with Anchor. What followed was an education in PDA design, account sizing discipline, and the surprising elegance of putting access control directly in the ledger state.


The Problem: Why On-Chain Keys?

Off-chain API keys have a single point of failure: your database. Compromise the database, rotate every key, notify every customer. The whole lifecycle — issuance, rotation, revocation, permission scoping — lives inside your infrastructure and is invisible to the rest of the ecosystem.

On-chain keys change the trust model. Any Solana program can verify that a key is valid, unexpired, and scoped to a particular permission set without making an HTTP call to your server. Composability becomes trivial — a DeFi protocol, an oracle network, or an NFT marketplace can gate access to their own instructions based on the on-chain state of your key registry.

The tradeoffs are real: you pay rent for account storage, every mutation is a transaction, and you cannot silently patch a bug. But for access-controlled developer tooling that needs to be auditable and composable, the tradeoffs are worth it.


Data Structure Design: PDAs for Key Storage

The core primitive is the Program Derived Address (PDA). A PDA is a deterministic account address derived from a set of seeds and a program ID — no private key, owned entirely by the program. This is exactly what you want for key storage: the address itself encodes ownership, and only your program can write to it.

I designed three account types:

ApiKeyRegistry — one per authority (the human or service that owns keys):

#[account]
pub struct ApiKeyRegistry {
    pub authority: Pubkey,       // 32 bytes
    pub key_count: u32,          // 4 bytes
    pub active_count: u32,       // 4 bytes
    pub created_at: i64,         // 8 bytes
    pub bump: u8,                // 1 byte
}

impl ApiKeyRegistry {
    pub const LEN: usize = 8    // discriminator
        + 32 + 4 + 4 + 8 + 1   // fields
        + 64;                    // future-proofing padding
}
Enter fullscreen mode Exit fullscreen mode

ApiKey — one per issued key, owned by the registry:

#[account]
pub struct ApiKey {
    pub registry: Pubkey,        // 32 bytes — parent registry
    pub authority: Pubkey,       // 32 bytes — redundant but useful for filtering
    pub key_hash: [u8; 32],      // 32 bytes — SHA-256 of the actual secret
    pub permissions: u64,        // 8 bytes  — bitmask
    pub created_at: i64,         // 8 bytes
    pub expires_at: Option<i64>, // 9 bytes
    pub revoked: bool,           // 1 byte
    pub rotation_count: u32,     // 4 bytes
    pub label: [u8; 32],         // 32 bytes — UTF-8, null-padded
    pub bump: u8,                // 1 byte
}

impl ApiKey {
    pub const LEN: usize = 8    // discriminator
        + 32 + 32 + 32 + 8 + 8 + 9 + 1 + 4 + 32 + 1
        + 64;                    // padding
}
Enter fullscreen mode Exit fullscreen mode

The PDA seeds for an ApiKey are [b"api-key", registry.key().as_ref(), key_index.to_le_bytes().as_ref()]. The index-based seed means each key has a stable, predictable address you can derive off-chain just by knowing the registry pubkey and the key's position.

Notice that we store key_hash, not the key itself. The actual secret lives only with the client. On-chain we store only the hash — consumers present the key, you hash it, you compare on-chain. The program never learns the plaintext secret.


Core Instructions

create_registry

The registry is the root of the tree. Every authority creates exactly one.

#[derive(Accounts)]
pub struct CreateRegistry<'info> {
    #[account(
        init,
        payer = authority,
        space = ApiKeyRegistry::LEN,
        seeds = [b"registry", authority.key().as_ref()],
        bump,
    )]
    pub registry: Account<'info, ApiKeyRegistry>,

    #[account(mut)]
    pub authority: Signer<'info>,

    pub system_program: Program<'info, System>,
}

pub fn create_registry(ctx: Context<CreateRegistry>) -> Result<()> {
    let registry = &mut ctx.accounts.registry;
    registry.authority = ctx.accounts.authority.key();
    registry.key_count = 0;
    registry.active_count = 0;
    registry.created_at = Clock::get()?.unix_timestamp;
    registry.bump = ctx.bumps.registry;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

create_api_key

This is the issuance instruction. The client generates a random 32-byte secret, hashes it off-chain with SHA-256, and passes the hash to the program. The program never sees the secret.

#[derive(Accounts)]
#[instruction(key_index: u32)]
pub struct CreateApiKey<'info> {
    #[account(
        mut,
        seeds = [b"registry", authority.key().as_ref()],
        bump = registry.bump,
        has_one = authority,
    )]
    pub registry: Account<'info, ApiKeyRegistry>,

    #[account(
        init,
        payer = authority,
        space = ApiKey::LEN,
        seeds = [b"api-key", registry.key().as_ref(), &key_index.to_le_bytes()],
        bump,
    )]
    pub api_key: Account<'info, ApiKey>,

    #[account(mut)]
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}

pub fn create_api_key(
    ctx: Context<CreateApiKey>,
    key_index: u32,
    key_hash: [u8; 32],
    permissions: u64,
    expires_at: Option<i64>,
    label: [u8; 32],
) -> Result<()> {
    let registry = &mut ctx.accounts.registry;

    require!(
        key_index == registry.key_count,
        ErrorCode::InvalidKeyIndex
    );

    if let Some(exp) = expires_at {
        require!(
            exp > Clock::get()?.unix_timestamp,
            ErrorCode::ExpiryInThePast
        );
    }

    let api_key = &mut ctx.accounts.api_key;
    api_key.registry = registry.key();
    api_key.authority = ctx.accounts.authority.key();
    api_key.key_hash = key_hash;
    api_key.permissions = permissions;
    api_key.created_at = Clock::get()?.unix_timestamp;
    api_key.expires_at = expires_at;
    api_key.revoked = false;
    api_key.rotation_count = 0;
    api_key.label = label;
    api_key.bump = ctx.bumps.api_key;

    registry.key_count += 1;
    registry.active_count += 1;

    emit!(ApiKeyCreated {
        registry: registry.key(),
        key_index,
        permissions,
    });

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

rotate_key

Rotation is the killer feature of this design. You replace the hash in-place, bump the rotation counter, and optionally reset the expiry. The PDA address stays the same — no need to update references elsewhere in your system.

pub fn rotate_key(
    ctx: Context<RotateApiKey>,
    new_key_hash: [u8; 32],
    new_expires_at: Option<i64>,
) -> Result<()> {
    let api_key = &mut ctx.accounts.api_key;

    require!(!api_key.revoked, ErrorCode::KeyRevoked);

    api_key.key_hash = new_key_hash;
    api_key.rotation_count += 1;
    api_key.expires_at = new_expires_at;

    emit!(ApiKeyRotated {
        registry: api_key.registry,
        rotation_count: api_key.rotation_count,
    });

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The RotateApiKey context is nearly identical to CreateApiKey but uses #[account(mut, has_one = authority)] instead of init. The has_one constraint enforces that only the original authority can rotate their own key — Anchor generates this check automatically.

revoke_key

Revocation is a single boolean flip. We keep the account alive rather than closing it so that audit history is preserved on-chain.

pub fn revoke_key(ctx: Context<RevokeApiKey>) -> Result<()> {
    let api_key = &mut ctx.accounts.api_key;
    require!(!api_key.revoked, ErrorCode::KeyAlreadyRevoked);

    api_key.revoked = true;
    ctx.accounts.registry.active_count -= 1;

    emit!(ApiKeyRevoked {
        registry: api_key.registry,
        revoked_at: Clock::get()?.unix_timestamp,
    });

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

verify_access — a CPI-friendly instruction

This is what makes the whole system composable. Other programs call verify_access via Cross-Program Invocation to check that a key hash is valid, unrevoked, unexpired, and has a required permission bit set:

pub fn verify_access(
    ctx: Context<VerifyAccess>,
    key_hash: [u8; 32],
    required_permission: u64,
) -> Result<()> {
    let api_key = &ctx.accounts.api_key;

    require!(!api_key.revoked, ErrorCode::KeyRevoked);
    require!(
        api_key.key_hash == key_hash,
        ErrorCode::InvalidKeyHash
    );
    require!(
        api_key.permissions & required_permission == required_permission,
        ErrorCode::InsufficientPermissions
    );

    if let Some(exp) = api_key.expires_at {
        require!(
            Clock::get()?.unix_timestamp < exp,
            ErrorCode::KeyExpired
        );
    }

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Security Patterns

Owner-Only Access via has_one

Anchor's has_one constraint is the cleanest way to enforce ownership. When you write has_one = authority on an account constraint, Anchor generates code equivalent to:

require!(api_key.authority == ctx.accounts.authority.key(), ErrorCode::ConstraintHasOne);
Enter fullscreen mode Exit fullscreen mode

This fires before your instruction handler body executes. It is not possible to accidentally forget the check — the constraint is structural.

PDA Ownership as Authorization

The program owns every ApiKey account. No external signer can write to these accounts directly. All mutations go through your program's instruction handlers, which is where your authorization logic lives. This is fundamentally different from a naive storage pattern where you might just check a signer — here, the account's writability is gated by the program itself.

No Plaintext Secrets on Chain

I want to re-emphasize this: the key hash scheme means the actual API secret is never transmitted to or stored by the program. The SHA-256 hash is a commitment to the secret. When a consumer presents their key, the verifying service computes sha256(presented_key) and passes that hash to verify_access. The program compares hashes. A compromised read of the chain state reveals nothing that can be used to authenticate.


Testing with Anchor

I used anchor test with the standard @solana/web3.js test harness. For unit-speed tests that do not need a full validator, bankrun (via solana-bankrun) is excellent — it runs in-process and is 10x faster.

Here is a representative test for the rotation flow:

it("rotates an API key and invalidates the old hash", async () => {
    const secret = crypto.getRandomValues(new Uint8Array(32));
    const keyHash = Array.from(
        new Uint8Array(await crypto.subtle.digest("SHA-256", secret))
    );

    // Issue key
    const keyIndex = 0;
    const [keyPda] = PublicKey.findProgramAddressSync(
        [Buffer.from("api-key"), registryPda.toBuffer(), 
         new BN(keyIndex).toArrayLike(Buffer, "le", 4)],
        program.programId
    );

    await program.methods
        .createApiKey(keyIndex, keyHash, new BN(0b111), null, labelBytes)
        .accounts({ registry: registryPda, apiKey: keyPda, authority: authority.publicKey })
        .signers([authority])
        .rpc();

    // Generate new secret and rotate
    const newSecret = crypto.getRandomValues(new Uint8Array(32));
    const newHash = Array.from(
        new Uint8Array(await crypto.subtle.digest("SHA-256", newSecret))
    );

    await program.methods
        .rotateKey(newHash, null)
        .accounts({ registry: registryPda, apiKey: keyPda, authority: authority.publicKey })
        .signers([authority])
        .rpc();

    const keyAccount = await program.account.apiKey.fetch(keyPda);
    expect(keyAccount.keyHash).to.deep.equal(newHash);
    expect(keyAccount.rotationCount).to.equal(1);

    // Old hash should fail verification
    try {
        await program.methods
            .verifyAccess(keyHash, new BN(0b001))
            .accounts({ apiKey: keyPda })
            .rpc();
        expect.fail("Should have thrown");
    } catch (err) {
        expect(err.error.errorCode.code).to.equal("InvalidKeyHash");
    }
});
Enter fullscreen mode Exit fullscreen mode

A few patterns I rely on consistently across the test suite:

  • Test isolation: each test creates a fresh authority keypair with Keypair.generate() so there is no state bleed between tests.
  • Error code assertions: Anchor surfaces custom errors as structured objects. Always assert err.error.errorCode.code rather than the message string — messages can change, codes are stable.
  • Boundary tests: test exactly at the permission bitmask boundary (required_permission = 0b100, key has 0b011), not just "permission denied" in general.

Real-World Considerations

Rent and Account Sizing

Every Solana account must maintain a minimum SOL balance to be rent-exempt. For ApiKey::LEN = 233 bytes, rent exemption costs roughly 0.0017 SOL at current rates (calculated via solana rent 233). The creator pays this at initialization time.

Getting LEN wrong is painful — you cannot resize accounts after initialization without closing and recreating them (or using realloc, which has its own complexity). My practice: pad every account with 64 bytes of reserved space and document it clearly. If you need a new field later, consume from the padding without a migration.

impl ApiKey {
    // If you add a field, shrink _reserved accordingly.
    // Never add fields beyond the initial reserved space without
    // a migration instruction that uses realloc.
    pub const LEN: usize = 8 + 159 + 64; // discriminator + fields + reserved
}
Enter fullscreen mode Exit fullscreen mode

Closing Accounts to Reclaim Rent

When a key is permanently decommissioned and you want rent back, add a close_key instruction:

#[derive(Accounts)]
pub struct CloseApiKey<'info> {
    #[account(
        mut,
        close = authority,   // Anchor sends lamports to authority and zeroes the account
        has_one = authority,
        constraint = api_key.revoked @ ErrorCode::MustRevokeBeforeClose,
    )]
    pub api_key: Account<'info, ApiKey>,

    #[account(mut)]
    pub authority: Signer<'info>,
}
Enter fullscreen mode Exit fullscreen mode

The close = authority attribute is Anchor's built-in account closing mechanism. It zeroes the data, sets the owner back to the System Program, and transfers the lamports — all in one constraint. I gate it behind revoked == true to prevent accidental key deletion.

Indexing and Discovery

On-chain accounts are not queryable by field value — there is no WHERE permissions & 0b010 > 0 equivalent. Discovery requires either:

  1. Maintaining an off-chain index (using a Geyser plugin or polling with getProgramAccounts filtered by memcmp on the authority field offset), or
  2. Storing key indices in the registry and deriving PDAs client-side.

I went with approach 2. The registry's key_count tells you how many keys exist. You derive each PDA by index and fetch in parallel. For a reasonable number of keys per authority (say, under 100), this is fast enough.


What I Would Do Differently

After building and deploying this system, a few things stand out:

Use realloc from the start. Rather than static padding, Anchor's realloc constraint lets you grow an account when needed. The setup is slightly more complex — you need realloc::payer and realloc::zero — but it is cleaner than burning 64 bytes of rent on every account forever.

Emit richer events. I initially kept events minimal. After the fact, I added key label and authority pubkey to every emitted event. Off-chain indexers are dramatically easier to build when events are self-contained.

Consider a permissions registry. The bitmask approach works, but it bakes the permission semantics into clients. A separate PermissionSchema account that maps bit positions to human-readable labels would make the system more self-documenting and easier to evolve.


Conclusion

Building API key management on-chain forced me to think about access control as a first-class data structure rather than a runtime check. The result is a system that any Solana program can query trustlessly, that maintains a permanent audit trail, and that gives developers rotatable, revocable, permission-scoped keys without depending on your backend being up.

The full implementation — including the complete Anchor program, TypeScript SDK, and 72-test suite — is available at github.com/TheAuroraAI/solana-api-key-manager. The program is deployed on devnet at 7uXfzJUYdVT3sENNzNcUPk7upa3RUzjB8weCBEeFQt58.

If you are building access-controlled developer tooling on Solana, I hope this design gives you a solid foundation to start from.

Top comments (0)