OpenKitx403

HTTP-native wallet authentication for Solana

OpenKitx403 is an open-source, TypeScript-first protocol that standardizes HTTP 403 as the semantic "prove you control this wallet" challenge for Solana wallet authentication.

Open Source MIT License Production Ready Type-Safe

Features

  • HTTP-Native: Uses standard HTTP 403 challenges, no custom protocols
  • Stateless: No required server-side sessions
  • Secure: Ed25519 signatures with replay protection
  • Easy to use: Drop-in middleware for Express, Fastify, and FastAPI
  • Production-ready: Full TypeScript and Python SDKs
  • AI-friendly: LangChain integration and agent support
  • Token-gating ready: Built-in support for NFT/token requirements

Why OpenKitx403?

Traditional web authentication requires centralized identity providers or long-lived sessions. OpenKitx403 enables stateless, cryptographic wallet-based authentication using standard HTTP semantics. It works with Phantom, Backpack, and Solflare wallets.

Client Example

import { OpenKit403Client } from '@openkitx403/client';

const client = new OpenKit403Client();
await client.connect('phantom');

const result = await client.authenticate({
    resource: 'https://api.example.com/protected'
});

console.log('Authenticated as:', result.address);

Server Example

import express from 'express';
import { createOpenKit403, inMemoryLRU } from '@openkitx403/server';

const app = express();
const openkit = createOpenKit403({
    issuer: 'my-api-v1',
    audience: 'https://api.example.com',
    replayStore: inMemoryLRU()
});

app.use(openkit.middleware());

app.get('/protected', (req, res) => {
    res.json({ wallet: req.openkitx403User.address });
});

How It Works

OpenKitx403 follows a simple challenge-response authentication flow:

1

Client Request

Client attempts to access protected resource

GET /protected

2

Server Challenge

Server responds with 403 + challenge

WWW-Authenticate: OpenKitx403 ...

3

User Signs

Wallet signs the challenge

Ed25519 signature

4

Client Retry

Client resends with Authorization header

Authorization: OpenKitx403 ...

5

Success

Server verifies signature and grants access

200 OK

No JWTs, No Sessions, No Central Authority

OpenKitx403 is stateless-by-design. The server verifies each request cryptographically without maintaining session state.

Quick Start

Get running in 5 minutes.

1. Install Client

npm install @openkitx403/client

2. Install Server

# TypeScript (Express/Fastify)
npm install @openkitx403/server

# Python (FastAPI)
pip install openkitx403

3. Setup Server

import express from 'express';
import { createOpenKit403, inMemoryLRU } from '@openkitx403/server';

const app = express();

const openkit = createOpenKit403({
    issuer: 'my-api',
    audience: 'https://api.example.com',
    replayStore: inMemoryLRU()
});

app.use(openkit.middleware());

app.get('/protected', (req, res) => {
    res.json({
        message: 'Hello!',
        wallet: req.openkitx403User.address
    });
});

app.listen(3000);

4. Setup Client

import { OpenKit403Client } from '@openkitx403/client';

const client = new OpenKit403Client();

// Connect wallet
await client.connect('phantom');

// Authenticate
const result = await client.authenticate({
    resource: 'http://localhost:3000/protected'
});

if (result.ok) {
    console.log('Success!', result.address);
}

Tip: See the Complete Examples section for more detailed implementations.

Installation

TypeScript Client

For browser and Node.js applications:

npm install @openkitx403/client

Supports:

  • Browser (Phantom, Backpack, Solflare wallets)
  • Node.js (with Solana keypair)
  • React, Vue, Svelte, etc.

TypeScript Server

For Express and Fastify servers:

npm install @openkitx403/server

Includes:

  • Express middleware
  • Fastify plugin
  • Proper Ed25519 verification (@noble/ed25519)
  • In-memory replay store

Python Server

For FastAPI applications:

pip install openkitx403
# or
poetry add openkitx403

Includes:

  • FastAPI middleware
  • Ed25519 verification (pynacl)
  • Type hints and async support

Client SDK

Browser Usage

Using OpenKitx403 in browser with wallet extensions:

import { OpenKit403Client } from '@openkitx403/client';

const client = new OpenKit403Client();

// 1. Connect to wallet
await client.connect('phantom'); // or 'backpack' or 'solflare'

// 2. Authenticate with API
const result = await client.authenticate({
    resource: 'https://api.example.com/protected',
    method: 'GET'
});

if (result.ok) {
    console.log('Authenticated as:', result.address);
    const data = await result.response?.json();
    console.log('API response:', data);
} else {
    console.error('Auth failed:', result.error);
}

React Example

import { useState } from 'react';
import { OpenKit403Client } from '@openkitx403/client';

function App() {
    const [client] = useState(() => new OpenKit403Client());
    const [address, setAddress] = useState();

    const login = async () => {
        await client.connect('phantom');
        const result = await client.authenticate({
            resource: 'https://api.example.com/user/profile'
        });
        
        if (result.ok) {
            setAddress(result.address);
        }
    };

    return (
        
{!address ? ( ) : (

Connected: {address}

)}
); }

Node.js Usage

Using OpenKitx403 in Node.js with Solana keypair:

import { Keypair } from '@solana/web3.js';
import bs58 from 'bs58';
import fetch from 'node-fetch';

// Load keypair
const keypair = Keypair.fromSecretKey(
    bs58.decode('YOUR_SECRET_KEY_BASE58')
);

// 1. Get challenge
const response1 = await fetch('https://api.example.com/protected');

if (response1.status === 403) {
    const wwwAuth = response1.headers.get('WWW-Authenticate');
    const challengeMatch = wwwAuth?.match(/challenge="([^"]+)"/);
    const challengeB64 = challengeMatch![1];
    
    // 2. Decode and sign challenge
    const challengeJson = Buffer.from(challengeB64, 'base64url').toString();
    const challenge = JSON.parse(challengeJson);
    const signingString = buildSigningString(challenge);
    const message = Buffer.from(signingString);
    const signature = nacl.sign.detached(message, keypair.secretKey);
    
    // 3. Build Authorization header
    const authHeader = `OpenKitx403 addr="${keypair.publicKey.toBase58()}", sig="${bs58.encode(signature)}", challenge="${challengeB64}", ts="${new Date().toISOString()}", nonce="${generateNonce()}", bind="GET:/protected"`;
    
    // 4. Retry with auth
    const response2 = await fetch('https://api.example.com/protected', {
        headers: { 'Authorization': authHeader }
    });
    
    console.log('Success:', await response2.json());
}

Server SDK

Express Middleware

import express from 'express';
import { createOpenKit403, inMemoryLRU } from '@openkitx403/server';

const app = express();

const openkit = createOpenKit403({
    issuer: 'my-api-v1',
    audience: 'https://api.example.com',
    ttlSeconds: 60,
    bindMethodPath: true,
    replayStore: inMemoryLRU()
});

// Apply to all routes
app.use(openkit.middleware());

// Or apply selectively
const protectedRouter = express.Router();
protectedRouter.use(openkit.middleware());

protectedRouter.get('/profile', (req, res) => {
    const user = req.openkitx403User;
    res.json({
        address: user.address,
        profile: { username: `User_${user.address.slice(0, 6)}` }
    });
});

app.use('/api', protectedRouter);
app.listen(3000);

Fastify Plugin

import Fastify from 'fastify';
import { createOpenKit403, inMemoryLRU } from '@openkitx403/server';

const fastify = Fastify();

const openkit = createOpenKit403({
    issuer: 'my-api-v1',
    audience: 'https://api.example.com',
    replayStore: inMemoryLRU()
});

// Add as hook
fastify.addHook('onRequest', async (request, reply) => {
    const hook = openkit.fastifyHook();
    const result = await hook(request, reply);
    if (result) return reply.send(result);
});

fastify.get('/protected', async (request, reply) => {
    const user = request.openkitx403User;
    return { address: user.address };
});

fastify.listen({ port: 3000 });

FastAPI Middleware

from fastapi import FastAPI, Depends
from openkitx403 import (
    OpenKit403Middleware,
    require_openkitx403_user,
    OpenKit403User
)

app = FastAPI()

# Add middleware
app.add_middleware(
    OpenKit403Middleware,
    audience="https://api.example.com",
    issuer="my-api-v1",
    ttl_seconds=60,
    bind_method_path=True,
    origin_binding=True,
    replay_backend="memory"
)

# Protected endpoint
@app.get("/protected")
async def protected(
    user: OpenKit403User = Depends(require_openkitx403_user)
):
    return {
        "message": "Hello!",
        "wallet": user.address
    }

# Exclude paths from auth
app.add_middleware(
    OpenKit403Middleware,
    audience="https://api.example.com",
    issuer="my-api-v1",
    excluded_paths=["/health", "/public"]
)

Token Gating Example

// TypeScript
const openkit = createOpenKit403({
    issuer: 'my-api',
    audience: 'https://api.example.com',
    replayStore: inMemoryLRU(),
    // Require NFT/token ownership
    tokenGate: async (address: string) => {
        const conn = new Connection('https://api.mainnet-beta.solana.com');
        const pubkey = new PublicKey(address);
        const tokenAccounts = await conn.getParsedTokenAccountsByOwner(
            pubkey,
            { mint: new PublicKey('YOUR_TOKEN_MINT') }
        );
        return tokenAccounts.value.length > 0;
    }
});
# Python
from solana.rpc.api import Client

solana_client = Client("https://api.mainnet-beta.solana.com")

async def check_token(address: str) -> bool:
    """Check if wallet holds required token"""
    try:
        pubkey = PublicKey(address)
        response = solana_client.get_token_accounts_by_owner(
            pubkey,
            {"mint": PublicKey("YOUR_TOKEN_MINT")}
        )
        return len(response.value) > 0
    except:
        return False

app.add_middleware(
    OpenKit403Middleware,
    audience="https://api.example.com",
    issuer="my-api",
    token_gate=check_token
)

Protocol Specification

HTTP Headers

Server → Client (Challenge)

HTTP/1.1 403 Forbidden
WWW-Authenticate: OpenKitx403 realm="api-v1", version="1", challenge="eyJ2IjoxLCJhbGci..."
Content-Type: application/json

{
    "error": "wallet_auth_required",
    "detail": "Sign the challenge using your Solana wallet."
}

Client → Server (Authorization)

GET /protected HTTP/1.1
Host: api.example.com
Authorization: OpenKitx403 addr="5Gv8...", sig="3kYz...", challenge="eyJ2...", ts="2025-11-05T10:30:00Z", nonce="X8p2...", bind="GET:/protected"

Challenge Format

The challenge is a JSON object (base64url-encoded):

{
    "v": 1,
    "alg": "ed25519-solana",
    "nonce": "E2o6p0q0Zl5PBjXc",
    "ts": "2025-11-05T10:30:00Z",
    "aud": "https://api.example.com",
    "method": "GET",
    "path": "/protected",
    "uaBind": false,
    "originBind": true,
    "serverId": "api-v1",
    "exp": "2025-11-05T10:31:00Z",
    "ext": {}
}

Field Definitions

Field Description
v Protocol version (must be 1)
alg Algorithm (must be "ed25519-solana")
nonce Server-generated random value
ts Challenge creation timestamp
aud Target audience (origin URL)
method HTTP method
path Request path
uaBind Require User-Agent binding
originBind Require Origin binding
serverId Server identifier
exp Expiration timestamp (60s default)
ext Extension data for custom requirements

Signing String

The signing string is constructed as follows:

OpenKitx403 Challenge
domain: https://api.example.com
server: api-v1
nonce: E2o6p0q0Zl5PBjXc
ts: 2025-11-05T10:30:00Z
method: GET
path: /protected
payload: {"alg":"ed25519-solana","aud":"https://api.example.com",...}

This string is signed with Ed25519 using the wallet's private key.

Verification Algorithm

The server verifies requests in 15 steps:

  1. Parse Authorization header
  2. Decode challenge from base64url
  3. Validate protocol version (v == 1)
  4. Validate algorithm (alg == "ed25519-solana")
  5. Check challenge not expired (current_time < exp)
  6. Validate audience matches
  7. Validate server ID matches
  8. Check timestamp within clock skew (±120s default)
  9. Verify method/path binding if enabled
  10. Verify origin binding if enabled
  11. Verify UA binding if enabled
  12. Check nonce not reused (replay protection)
  13. Verify Ed25519 signature
  14. Check token gate if configured
  15. Success - attach user to request

Full Specification: See docs/COMPLETE_SPECIFICATION.md in the source package for the complete RFC-style protocol specification.

LangChain & AI Agents

OpenKitx403 supports autonomous agents and LangChain tools for wallet authentication.

LangChain Tool

import { OpenKit403Client } from '@openkitx403/client';
import { Tool } from 'langchain/tools';

export class SolanaWalletAuthTool extends Tool {
    name = "solana_wallet_auth";
    description = "Authenticate with Solana wallet to access protected APIs";
    private client: OpenKit403Client;

    constructor() {
        super();
        this.client = new OpenKit403Client();
    }

    async _call(input: string): Promise<string> {
        const params = Object.fromEntries(
            input.split(',').map(p => p.split('='))
        );

        await this.client.connect(params.wallet as any);
        
        const result = await this.client.authenticate({
            resource: params.url,
            method: params.method || 'GET'
        });

        if (result.ok) {
            const data = await result.response?.json();
            return JSON.stringify({ success: true, data });
        }
        
        return JSON.stringify({ success: false, error: result.error });
    }
}

Usage with LangChain Agent

import { initializeAgentExecutorWithOptions } from 'langchain/agents';
import { ChatOpenAI } from 'langchain/chat_models/openai';

const tools = [new SolanaWalletAuthTool()];
const model = new ChatOpenAI({ temperature: 0 });

const executor = await initializeAgentExecutorWithOptions(tools, model, {
    agentType: "zero-shot-react-description",
    verbose: true,
});

const result = await executor.call({
    input: "Connect my Phantom wallet and fetch my NFT collection"
});

console.log(result.output);

Autonomous Agent

class WalletAuthAgent {
    private client: OpenKit403Client;

    constructor() {
        this.client = new OpenKit403Client();
    }

    async executeTask(task: {
        action: 'authenticate' | 'fetch' | 'post';
        url: string;
        wallet?: string;
        data?: any;
    }) {
        if (!this.client) {
            await this.client.connect((task.wallet || 'phantom') as any);
        }

        const result = await this.client.authenticate({
            resource: task.url,
            method: task.action === 'post' ? 'POST' : 'GET',
            body: task.data
        });

        return {
            success: result.ok,
            address: result.address,
            data: result.ok ? await result.response?.json() : null
        };
    }
}

// Usage
const agent = new WalletAuthAgent();

await agent.executeTask({
    action: 'authenticate',
    url: 'https://api.example.com/auth',
    wallet: 'phantom'
});

await agent.executeTask({
    action: 'fetch',
    url: 'https://api.example.com/nfts'
});

await agent.executeTask({
    action: 'post',
    url: 'https://api.example.com/trade',
    data: { action: 'buy', amount: 100 }
});

Security

Security Features

  • Ed25519 Signatures: Industry-standard cryptographic verification
  • Replay Protection: Nonce-based prevention with configurable store
  • Short TTL: Challenges expire in 60 seconds by default
  • Method/Path Binding: Prevents cross-endpoint replay attacks
  • Origin Binding: CORS protection (optional)
  • Clock Skew Tolerance: ±120 seconds to handle time drift
  • Token Gating: Verify NFT/token ownership before access

Best Practices

  • Always use HTTPS in production
  • Enable replay protection with replay store
  • Use method/path binding for sensitive operations
  • Set appropriate TTL (60s recommended)
  • Implement rate limiting to prevent abuse
  • Monitor for anomalies (unusual patterns, IPs)
  • Keep dependencies updated

Threats Mitigated

  • Replay attacks (nonce + TTL + replay store)
  • Cross-endpoint replay (method/path binding)
  • Cross-origin replay (origin binding)
  • Clock skew attacks (timestamp validation)
  • Unsigned requests (signature verification)

Limitations

  • Does not protect against compromised wallets
  • Does not prevent phishing attacks
  • Requires user approval for each signature
Security Notice

OpenKitx403 is for authentication, not message-level encryption. Always use HTTPS in production.

Complete Examples

Browser + Express Full Stack

Frontend (React)

import { useState } from 'react';
import { OpenKit403Client } from '@openkitx403/client';

function App() {
    const [client] = useState(() => new OpenKit403Client());
    const [address, setAddress] = useState();
    const [profile, setProfile] = useState();

    const login = async () => {
        await client.connect('phantom');
        
        const result = await client.authenticate({
            resource: 'http://localhost:3000/api/profile',
            method: 'GET'
        });

        if (result.ok) {
            setAddress(result.address);
            const data = await result.response?.json();
            setProfile(data);
        }
    };

    return (
        
{!address ? ( ) : (

Wallet: {address}

{JSON.stringify(profile, null, 2)}
)}
); }

Backend (Express)

import express from 'express';
import cors from 'cors';
import { createOpenKit403, inMemoryLRU } from '@openkitx403/server';

const app = express();
app.use(cors());

const openkit = createOpenKit403({
    issuer: 'my-app',
    audience: 'http://localhost:3000',
    ttlSeconds: 60,
    replayStore: inMemoryLRU()
});

app.use('/api', openkit.middleware());

app.get('/api/profile', (req, res) => {
    const user = req.openkitx403User;
    res.json({
        address: user.address,
        username: `User_${user.address.slice(0, 6)}`,
        nftCount: 5,
        joinedAt: new Date().toISOString()
    });
});

app.listen(3000);

FastAPI + Python Client

Server (FastAPI)

from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from openkitx403 import OpenKit403Middleware, require_openkitx403_user

app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"])

app.add_middleware(
    OpenKit403Middleware,
    audience="http://localhost:8000",
    issuer="my-api",
    ttl_seconds=60
)

@app.get("/protected")
async def protected(user = Depends(require_openkitx403_user)):
    return {
        "address": user.address,
        "message": f"Hello, {user.address[:6]}!"
    }

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

Client (Python)

import requests
from solana.keypair import Keypair
import base58

keypair = Keypair.generate()

# 1. Get challenge
response = requests.get("http://localhost:8000/protected")

if response.status_code == 403:
    www_auth = response.headers.get('WWW-Authenticate')
    challenge_b64 = re.search(r'challenge="([^"]+)"', www_auth).group(1)
    
    # 2. Sign challenge (implementation details omitted for brevity)
    signature = sign_challenge(challenge_b64, keypair)
    
    # 3. Retry with auth
    auth_header = build_auth_header(keypair.public_key, signature, challenge_b64)
    
    response = requests.get(
        "http://localhost:8000/protected",
        headers={'Authorization': auth_header}
    )
    
    print(response.json())

More Examples: See USAGE_EXAMPLES.md in the source package for 8 complete sections of examples including Node.js, Fastify, and agent patterns.

API Reference

TypeScript Client

OpenKit403Client

Constructor
new OpenKit403Client(opts?: { wallet?: WalletProvider })
Methods
connect(wallet: WalletProvider): Promise<void>

Connect to a Solana wallet (phantom, backpack, or solflare).

authenticate(options: AuthOptions): Promise<AuthResult>

Authenticate with a protected resource. Handles complete 403 flow.

  • options.resource: API endpoint URL
  • options.method: HTTP method (default: 'GET')
  • options.headers: Additional headers
  • options.body: Request body for POST/PUT
signChallenge(challengeB64: string): Promise<{signature: string, address: string}>

Sign a challenge with the connected wallet.

TypeScript Server

createOpenKit403(config: OpenKit403Config)

Configuration
interface OpenKit403Config {
    issuer: string;              // Server identifier
    audience: string;            // Expected origin URL
    ttlSeconds?: number;         // Challenge TTL (default: 60)
    bindMethodPath?: boolean;    // Enable method/path binding
    originBinding?: boolean;     // Enable origin verification
    uaBinding?: boolean;         // Enable UA verification
    replayStore?: ReplayStore;   // Nonce store for replay protection
    tokenGate?: (address: string) => Promise<boolean>; // Token gating function
    clockSkewSeconds?: number;   // Clock skew tolerance (default: 120)
}
Returns
{
    createChallenge: (method: string, path: string) => { headerValue: string, challengeJson: Challenge },
    verifyAuthorization: (authHeader: string, method: string, path: string) => Promise<OpenKit403User>,
    middleware: () => ExpressMiddleware,
    fastifyHook: () => FastifyHook
}

inMemoryLRU(): ReplayStore

Create an in-memory replay store with LRU eviction.

Python Server

OpenKit403Middleware

Configuration
OpenKit403Middleware(
    audience: str,                    # Expected origin URL
    issuer: str,                      # Server identifier
    ttl_seconds: int = 60,            # Challenge TTL
    clock_skew_seconds: int = 120,    # Clock skew tolerance
    bind_method_path: bool = False,   # Enable method/path binding
    origin_binding: bool = False,     # Enable origin verification
    ua_binding: bool = False,         # Enable UA verification
    replay_backend: str = "memory",   # Replay store backend
    token_gate: Optional[Callable[[str], bool]] = None,  # Token gate function
    excluded_paths: Optional[List[str]] = None  # Paths to exclude from auth
)

require_openkitx403_user

FastAPI dependency that extracts authenticated user from request.

@app.get("/protected")
async def protected(user: OpenKit403User = Depends(require_openkitx403_user)):
    return {"address": user.address}

OpenKit403User

Authenticated user type.

class OpenKit403User:
    address: str                              # Base58-encoded Solana public key
    challenge: Optional[Dict[str, Any]]       # Original challenge data