Skip to main content

Production-ready Bittensor authentication for Python web frameworks. SR25519 signature verification, metagraph-based registration checks, nonce replay protection, and session management.

Project description

bittensor-auth

Production-ready Bittensor authentication for Python web frameworks.

bittensor-auth is a small, focused library that gives any Bittensor subnet operator a drop-in authentication layer: SR25519 signature verification, metagraph-based registration and validator-permit checks, nonce replay protection, and a challenge/response session flow — with first-class FastAPI bindings and a signing httpx transport for clients.

Why this exists

Every Bittensor subnet needs to verify SR25519 signatures from hotkeys, check metagraph registration, protect against nonce replay, and manage sessions — but there is no standard library for it. Each team rewrites the same plumbing from scratch.

The Bittensor ecosystem uses several signing conventions:

Convention Used by Message format
Bittensor native Axon/Synapse communication nonce.sender.receiver.uuid.body_hash
Epistula SN4 Targon, SN19 Vision sha256(body).uuid.timestamp.signed_for
Colon-separated ORO, similar to Chutes {hotkey}:{timestamp}:{nonce}
Custom ResiLabs, Taoshi, others Varies per subnet

This package provides the common building blocks — SR25519 verification, metagraph caching, nonce replay protection, session management — with a pluggable message format so you can use whichever signing convention your subnet needs.

It is not specific to any subnet. There are no "miner" / "admin" concepts in the public API, no assumptions about database schema, and no hard dependency on Redis or any particular web framework.

Install

pip install bittensor-auth             # core only
pip install bittensor-auth[fastapi]    # + FastAPI dependencies / router
pip install bittensor-auth[redis]      # + Redis-backed CacheBackend
pip install bittensor-auth[client]     # + httpx signing transport
pip install bittensor-auth[all]        # everything

Quickstart — FastAPI server (15 lines)

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from bittensor_auth import BittensorAuthConfig, InMemoryCache, MetagraphCache
from bittensor_auth.fastapi import AuthenticatedUser, BittensorAuth

config = BittensorAuthConfig(subnet_netuid=9, subtensor_network="finney")
cache = InMemoryCache()
metagraph = MetagraphCache(config)
auth = BittensorAuth(config=config, cache=cache, metagraph=metagraph)

@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
    await metagraph.start()
    try:
        yield
    finally:
        await metagraph.stop()

app = FastAPI(lifespan=lifespan)

@app.get("/me")
async def me(user: AuthenticatedUser = Depends(auth.require_registered)) -> dict:
    return {"hotkey": user.hotkey}

That's it. Any request to GET /me must carry a valid X-Hotkey/X-Timestamp/X-Nonce/X-Signature quartet produced by a hotkey that is registered on subnet 9. Unregistered hotkeys get 403; bad signatures, stale timestamps, and replayed nonces get 401; malformed input gets 400.

Note: metagraph.start() performs an initial sync with the Bittensor chain, which takes 5-30 seconds depending on the network. The server will not accept requests until the sync completes. On test or local networks this is faster.

Ranking by role: require_validator

@app.get("/admin")
async def admin(user: AuthenticatedUser = Depends(auth.require_validator)) -> dict:
    return {"stake": metagraph.get_stake_weight(user.hotkey)}

The full challenge/session flow

If you want long-lived bearer tokens rather than signing every request (e.g. for browser wallets):

from bittensor_auth import SessionStore
from bittensor_auth.fastapi import build_auth_router

session_store = SessionStore(cache)

# Pass session_store so require_auth and require_session work
auth = BittensorAuth(
    config=config, cache=cache, metagraph=metagraph,
    session_store=session_store,
)

async def resolve_role(hotkey: str) -> str | None:
    if metagraph.has_validator_permit(hotkey):
        return "validator"
    if metagraph.is_hotkey_registered(hotkey):
        return "user"
    return None

app.include_router(
    build_auth_router(session_store=session_store, role_resolver=resolve_role),
    prefix="/auth",
)

Mounts POST /auth/challenge, POST /auth/session, POST /auth/logout.

Use require_auth on your endpoints to accept both bearer tokens and per-request signing — browser wallets use the session token, server-side clients sign each request:

@app.get("/me")
async def me(user: AuthenticatedUser = Depends(auth.require_auth)) -> dict:
    return {"hotkey": user.hotkey, "role": user.role}
Dependency Bearer token Per-request signing Use case
auth.require_registered No Yes Server-to-server APIs
auth.require_validator No Yes Validator-only endpoints
auth.require_session Yes No Browser-only endpoints
auth.require_auth Yes (preferred) Fallback Most endpoints

Custom message formats

The default signing message is {hotkey}:{timestamp}:{nonce} (the colon_separated preset). If your subnet uses a different format, pass a custom message_builder — a function (hotkey, timestamp, nonce) -> str:

# Use the dot-separated preset
from bittensor_auth import dot_separated

auth = BittensorAuth(
    config=config, cache=cache, metagraph=metagraph,
    message_builder=dot_separated,  # {hotkey}.{timestamp}.{nonce}
)

Or define your own:

def my_subnet_message(hotkey: str, timestamp: str | int, nonce: str) -> str:
    """My subnet signs {nonce}:{hotkey}:{timestamp}."""
    return f"{nonce}:{hotkey}:{timestamp}"

auth = BittensorAuth(
    config=config, cache=cache, metagraph=metagraph,
    message_builder=my_subnet_message,
)

The same message_builder parameter is accepted by generate_auth_headers, verify_signature, SigningTransport, AsyncSigningTransport, and BittensorAuthClient — client and server must agree on the same builder.

For protocols that include a request body hash (e.g. Epistula), implement a builder that captures the hash from a higher layer:

def make_epistula_builder(body_hash: str, signed_for: str = "") -> MessageBuilder:
    """Epistula-style: {body_hash}.{nonce}.{timestamp}.{signed_for}

    Call this per-request with the actual body hash, then pass the
    returned builder to verify_signature or generate_auth_headers.
    """
    def builder(hotkey: str, timestamp: str | int, nonce: str) -> str:
        return f"{body_hash}.{nonce}.{timestamp}.{signed_for}"
    return builder

Python client (signing httpx transport)

from bittensor import Keypair
from bittensor_auth import BittensorAuthClient

keypair = Keypair.create_from_uri("//Alice")  # or Wallet(name=..., hotkey=...)

with BittensorAuthClient(base_url="https://api.example.com", signer=keypair) as c:
    httpx_client = c.get_httpx_client()
    resp = httpx_client.get("/me")

Every non-public request is transparently signed with fresh X-Hotkey/X-Timestamp/X-Nonce/X-Signature headers. Need an async client? Call c.get_async_httpx_client() instead.

If you want to drop your own transport into an existing httpx.Client:

import httpx
from bittensor_auth import SigningTransport

client = httpx.Client(
    base_url="https://api.example.com",
    transport=SigningTransport(keypair),
)

Or compute headers by hand for a non-httpx transport:

from bittensor_auth import generate_auth_headers

headers = generate_auth_headers(keypair)
# {'X-Hotkey': '...', 'X-Timestamp': '...', 'X-Nonce': '...', 'X-Signature': '0x...'}

Browser client (polkadot.js)

import { stringToHex } from '@polkadot/util';
import { web3FromAddress } from '@polkadot/extension-dapp';

async function signedHeaders(address: string): Promise<Record<string, string>> {
  const timestamp = String(Math.floor(Date.now() / 1000));
  const nonce = crypto.randomUUID();
  const message = `${address}:${timestamp}:${nonce}`;

  const injector = await web3FromAddress(address);
  const { signature } = await injector.signer.signRaw!({
    address,
    data: stringToHex(message),
    type: 'bytes',
  });

  return {
    'X-Hotkey': address,
    'X-Timestamp': timestamp,
    'X-Nonce': nonce,
    'X-Signature': signature, // already 0x-prefixed hex
  };
}

The server happily accepts 0x-prefixed signatures from polkadot.js without any client-side fixup.

Important: Use signRaw, not signPayload. The signPayload method wraps messages with <Bytes>...</Bytes> which breaks server-side verification. signRaw signs the raw hex bytes, which is what the server expects.

Challenge/session flow from the browser

For long-lived sessions instead of per-request signing:

import { stringToHex } from '@polkadot/util';
import { web3Enable, web3FromAddress } from '@polkadot/extension-dapp';

const API_BASE = 'https://api.your-subnet.com';

async function login(address: string): Promise<string> {
  // 1. Enable the wallet extension
  await web3Enable('My Subnet App');
  const injector = await web3FromAddress(address);

  // 2. Request a challenge from the server
  const challengeResp = await fetch(`${API_BASE}/auth/challenge`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ hotkey: address }),
  });
  const { challenge } = await challengeResp.json();

  // 3. Sign the challenge with the wallet (signRaw, NOT signPayload)
  const { signature } = await injector.signer.signRaw!({
    address,
    data: stringToHex(challenge),
    type: 'bytes',
  });

  // 4. Exchange the signed challenge for a session token
  const sessionResp = await fetch(`${API_BASE}/auth/session`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ hotkey: address, challenge, signature }),
  });
  const { session_token, role } = await sessionResp.json();

  // 5. Use the bearer token for all subsequent requests
  return session_token;
}

// Authenticated request using the session token
async function fetchMe(token: string) {
  const resp = await fetch(`${API_BASE}/me`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  return resp.json();
}

Using Redis in production

Swap InMemoryCache for RedisCache:

from bittensor_auth import RedisCache

cache = RedisCache.from_url("redis://localhost:6379/0")

RedisCache wraps redis.asyncio. You can also pass a pre-configured redis.asyncio.Redis client if you already manage connections elsewhere — just construct it with decode_responses=True.

Configuration reference

BittensorAuthConfig is a frozen dataclass; all fields have production-safe defaults and may be overridden per deployment.

Field Default Meaning
subnet_netuid 1 Subnet UID registration is checked against.
subtensor_network "finney" Network name (finney/test/local) or a ws(s):// URL.
timestamp_skew_seconds 60 Clock-skew window; also the nonce replay-protection TTL.
validator_min_stake 0.0 Minimum TAO stake required for has_validator_permit to pass. 0 disables the stake check.
metagraph_refresh_interval 300 Seconds between background metagraph syncs.
session_ttl_seconds 7200 Lifetime of session tokens issued by the router.
challenge_ttl_seconds 60 Lifetime of /challenge nonces.
max_nonce_length 256 Max character length of client-supplied nonces — defends against cache-key DoS.
recheck_registration_on_session True If True, Bearer-token auth re-resolves role each request via role_resolver. Disable only if you accept role staleness up to session_ttl_seconds.
recheck_ban_on_session True If True, Bearer-token auth rechecks metagraph registration each request. Makes deregistrations take effect immediately.
metagraph_max_age_seconds 1200 Maximum age of the cached metagraph snapshot before queries fail closed. Guards against silent chain partitions freezing ban/registration state. Set to 0 to disable.

Session semantics

The package offers two authentication modes and they have different freshness contracts:

  • Per-request signing (authenticate, require_registered, require_validator) — every request re-runs signature verification, metagraph registration, and the ban check. The request is as fresh as the metagraph snapshot.

  • Bearer-token sessions (require_session, require_auth) — the challenge/response flow exchanges a signed challenge for a short-lived Bearer token stored server-side. By default the role and registration are re-checked on every request (recheck_registration_on_session=True, recheck_ban_on_session=True), so a hotkey that deregisters from the subnet loses access on its next call. If you opt out of these flags, role and registration are frozen at session-creation time for up to session_ttl_seconds (default 2 h) — only flip them off if you have an explicit reason.

Banning a hotkey calls SessionStore.revoke_all_sessions, which uses the atomic smembers_and_delete primitive so there's no classic check-then-delete race. A truly concurrent create_session can still land a session in a fresh index after revocation finishes; pair revoke_all_sessions with a per-request ban_checker to catch that survivor on its next call. See SECURITY.md for the full threat model.

Deployment requirements

The package covers authentication; a few adjacent concerns are your responsibility:

  • Rate-limit /challenge. The endpoint only format-validates the claimed hotkey, so anyone who can reach the server can create cache entries. Put a rate limiter (ingress or framework level) in front of it — a per-IP budget on the order of 10–30 req/min and a per-hotkey budget of a handful per minute is a sensible starting point.
  • Use RedisCache in production. InMemoryCache is process-local (sessions/nonces don't cross workers) and has no background sweeper. It's for tests and single-process development only.
  • Monitor MetagraphCache.last_synced_at. Expose the staleness in your alerting so you hear about a chain-endpoint partition before requests start failing closed.
  • Keep verify_ssl=True on the client. The SDK transport defaults to TLS verification; don't flip it off in production.

Public API at a glance

# Core primitives (framework-agnostic)
from bittensor_auth import (
    BittensorAuthConfig, AuthErrorCode, AuthenticationError,
    verify_sr25519, validate_hotkey_format, parse_signature,
    construct_signing_message, validate_timestamp, verify_signature,
    MessageBuilder, colon_separated, dot_separated,
    CacheBackend, InMemoryCache, RedisCache,
    NonceTracker,
    MetagraphCache, MetagraphLike,
    SessionStore, SessionData, ChallengeData,
    generate_session_token, generate_challenge, extract_nonce_from_challenge,
)

# Client transport (requires bittensor-auth[client])
from bittensor_auth import (
    BittensorAuthClient, SigningTransport, AsyncSigningTransport,
    generate_auth_headers, default_is_public_endpoint,
)

# FastAPI integration (requires bittensor-auth[fastapi])
from bittensor_auth.fastapi import (
    BittensorAuth, AuthenticatedUser, RoleResolver, BanChecker,
    HEADER_HOTKEY, HEADER_TIMESTAMP, HEADER_NONCE, HEADER_SIGNATURE,
    build_auth_router,
    ChallengeRequest, ChallengeResponse,
    SessionRequest, SessionResponse, LogoutResponse,
    auth_error_to_http,
)

Migrating from an inline auth middleware

If your subnet already has hand-rolled auth code, the most mechanical migration path is:

  1. Replace your verify_signature(hotkey, timestamp, nonce, signature) helper with bittensor_auth.verify_signature — the signing message format and header names are already the ecosystem standard.
  2. Swap your Redis replay-protection code for NonceTracker — it uses set_if_not_exists for single-round-trip atomicity.
  3. Replace your metagraph-sync loop with MetagraphCache.start() — drop the manual ThreadPoolExecutor and websocket locking.
  4. For FastAPI, replace @requires_auth decorators with Depends(auth.require_registered) / Depends(auth.require_validator).

The AuthErrorCode enum values match the de-facto codes most subnets already emit (NOT_REGISTERED, TIMESTAMP_SKEW, NONCE_REUSED, …), so clients that parse the old error codes keep working.

What this package explicitly does NOT do

  • Rate limiting. That belongs in host middleware (slowapi, nginx, CloudFront, …). Wrapping the router with a rate-limit library would couple every consumer to that library's lifecycle.
  • User/validator databases. role_resolver and ban_checker are hooks; your application decides what a "role" is and where it's stored.
  • Opinionated retry/backoff on the client. BittensorAuthClient gives you a wired httpx client; wrap it in your own retry transport if you need one.

Examples

  • examples/server.py — minimal FastAPI server with /me + the session router
  • examples/client.py — Python client signing requests with SigningTransport

License

Apache-2.0.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

bittensor_auth-0.1.1.tar.gz (1.5 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

bittensor_auth-0.1.1-py3-none-any.whl (35.2 kB view details)

Uploaded Python 3

File details

Details for the file bittensor_auth-0.1.1.tar.gz.

File metadata

  • Download URL: bittensor_auth-0.1.1.tar.gz
  • Upload date:
  • Size: 1.5 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for bittensor_auth-0.1.1.tar.gz
Algorithm Hash digest
SHA256 11dc1936098ea7201fbad3de7156aaa6154d243bb9db3ac9fccce964fe4bfddd
MD5 3bfb7d772fd324d9b9c44bfa18e7df05
BLAKE2b-256 c4d0c27114eebcf4f425b25682e4ff6ca0dc6fab5d9dc948a36a1474eef9b287

See more details on using hashes here.

Provenance

The following attestation bundles were made for bittensor_auth-0.1.1.tar.gz:

Publisher: publish.yml on ORO-AI/bittensor-auth

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file bittensor_auth-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: bittensor_auth-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 35.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for bittensor_auth-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 0683ecd81be795d28b490d0a19ca6d6ccc0baff68ebdb03f5fb6e8c03740cbdd
MD5 b854d0ef10cad62722ea940d9312f865
BLAKE2b-256 47eb67d16cf7fb491c21ca60e127da5c5420bc80a7a2b0706d4cc383adf410c6

See more details on using hashes here.

Provenance

The following attestation bundles were made for bittensor_auth-0.1.1-py3-none-any.whl:

Publisher: publish.yml on ORO-AI/bittensor-auth

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page