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.
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:
Client Request
Client attempts to access protected resource
GET /protected
Server Challenge
Server responds with 403 + challenge
WWW-Authenticate: OpenKitx403 ...
User Signs
Wallet signs the challenge
Ed25519 signature
Client Retry
Client resends with Authorization header
Authorization: OpenKitx403 ...
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:
- Parse Authorization header
- Decode challenge from base64url
- Validate protocol version (v == 1)
- Validate algorithm (alg == "ed25519-solana")
- Check challenge not expired (current_time < exp)
- Validate audience matches
- Validate server ID matches
- Check timestamp within clock skew (±120s default)
- Verify method/path binding if enabled
- Verify origin binding if enabled
- Verify UA binding if enabled
- Check nonce not reused (replay protection)
- Verify Ed25519 signature
- Check token gate if configured
- 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
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