fishnet-auth
v0.1.0-beta.4
Published
Reverse CAPTCHA authentication for AI agents. Prove you're a machine, not a human.
Maintainers
Readme
fishnet-auth
Reverse CAPTCHA authentication for AI agents. Prove you're a machine, not a human.
fishnet-auth is an open-source auth library that lets game servers and agent-facing APIs verify that incoming clients are real AI agents -- not humans, not dumb bots. Think NextAuth, but instead of proving "I'm human," your clients prove "I can think."
Drop it into your Next.js, Hono, or Express app. Bring your own database. You own everything.
Why
CAPTCHAs prove you're human. But what if your platform is for AI agents?
Agent-facing platforms -- games, simulations, competitions -- need the opposite: proof that a client has LLM-level reasoning capability. API keys alone don't prove intelligence. fishnet-auth does.
The idea: On every auth request, the server generates a set of micro-tasks that are trivial for an LLM but impractical for a human to solve within the time constraint. Tasks are generated procedurally, verified deterministically, and cost zero LLM tokens on your server.
How It Works
1. Agent reads a public seed from your server (rotates every 5 min)
2. Agent solves tasks derived from that seed (their LLM, their cost)
3. Agent submits answers in a single POST request
4. Server re-derives tasks from seed, verifies answers (~1ms, pure computation)
5. Server issues credentials via your databaseOne HTTP round trip. Stateless verification. No LLM cost on your server.
+------------------------------+
| Your Game API |
+------------------------------+
| fishnet-auth (this lib) | <- you install this
| challenge gen . verification |
| seed rotation . middleware |
+------------------------------+
| Your DB Adapter | <- you configure this
| (Supabase / Redis / Drizzle)|
+------------------------------+Quick Start
Install
npm install fishnet-auth1. Add the auth route
// app/api/agent-auth/[[...fishnet-auth]]/route.ts
import { fishnetAuth } from 'fishnet-auth';
import { SupabaseAdapter } from 'fishnet-auth/adapters/supabase';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
export const { GET, POST } = fishnetAuth({
secret: process.env.FISHNET_AUTH_SECRET!,
adapter: SupabaseAdapter(supabase),
});Note: The route uses an optional catch-all
[[...fishnet-auth]](double brackets), not a required catch-all[...fishnet-auth]. This ensuresPOST /api/agent-auth(with no sub-path) resolves correctly.
2. Protect your routes
// app/api/game/route.ts
import { getAgent } from 'fishnet-auth';
export async function POST(req: Request) {
const agent = await getAgent(req);
if (!agent) {
return Response.json({ error: 'unauthorized' }, { status: 401 });
}
// agent.id, agent.name, agent.createdAt
}Alternatively, use createGetAgent to avoid reliance on global state:
import { createGetAgent } from 'fishnet-auth';
import { SupabaseAdapter } from 'fishnet-auth/adapters/supabase';
const getAgent = createGetAgent({
secret: process.env.FISHNET_AUTH_SECRET!,
adapter: SupabaseAdapter(supabase),
});
export async function POST(req: Request) {
const agent = await getAgent(req);
if (!agent) return Response.json({ error: 'unauthorized' }, { status: 401 });
}That's it. Two files and your API is agent-authenticated.
Agent-Side SDK
Agents authenticate using the client SDK. They bring their own LLM as the solver:
import { AgentAuthClient } from 'fishnet-auth/client';
const auth = new AgentAuthClient({
serverUrl: 'https://your-game.com',
name: 'MyPokerBot',
solver: async (tasks) => {
// Agent uses their own LLM to solve the challenge tasks
const response = await myLLM.complete(formatTasks(tasks));
return parseAnswers(response);
},
});
const { apiKey } = await auth.authenticate();
// Use the key for all subsequent requests
fetch('https://your-game.com/api/game/action', {
headers: { Authorization: `Bearer ${apiKey}` },
});Agents can also implement the protocol manually -- it's just two HTTP calls:
# 1. Get the current seed and your tasks
curl "https://your-game.com/.well-known/fishnet-auth?name=MyBot"
# 2. Solve and submit
curl -X POST https://your-game.com/api/agent-auth \
-H "Content-Type: application/json" \
-d '{"name":"MyBot","seed":"a8f2c9","answers":["xK9mQ2nL","apple",...]}'Client Configuration
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| serverUrl | string | required | The game server base URL |
| name | string | required | Agent name |
| solver | function | required | Solver function (your LLM) |
| discoveryPath | string | /.well-known/fishnet-auth | Custom discovery path |
| authPath | string | discovered from server | Custom auth path |
| maxRetries | number | 2 | Max retries on failure |
| retryDelay | number | 1000 | Retry delay in ms (exponential backoff) |
Protocol Spec
Discovery
GET /.well-known/fishnet-auth?name=MyBot{
"version": "0.1",
"seed": "a8f2c9",
"seedExpiresAt": "2026-02-07T12:05:00Z",
"taskTypes": ["reverse", "nthWord", "jsonBuild", "arraySort", "caesarShift", "pattern", "transform", "firstLetters", "wordLengths", "filterWords", "interleave"],
"taskCount": 5,
"minCorrect": 5,
"authEndpoint": "/api/agent-auth",
"tasks": [
{ "type": "reverse", "instruction": "Reverse this string exactly.", "input": "xK9mQ2nL" },
{ "type": "nthWord", "instruction": "What is word number 7 in this list? (1-indexed)", "input": ["quantum", "nebula", "prism", "velvet", "cascade", "ember", "fractal", "horizon", "lattice"] }
]
}The minCorrect field tells the agent how many tasks it must answer correctly to pass. This allows operators to configure a pass threshold (e.g., 4 out of 5 must be correct) rather than requiring perfect scores.
Authenticate
POST /api/agent-auth{
"name": "MyPokerBot",
"seed": "a8f2c9",
"answers": ["LnQ2m9Kx", "cascade", ...]
}Success (200):
{
"agentId": "ag_k8x2m9f1",
"apiKey": "ag_a8Kx92mN...",
"expiresAt": "2026-03-07T12:00:00Z"
}Failure (401):
{
"error": "challenge_failed",
"message": "Challenge verification failed. Please try again."
}Seed Rotation
Seeds rotate on a configurable interval (default: 5 minutes). The server accepts both the current and previous seed to handle requests in flight during rotation. Each (seed, agentName) pair produces a unique task set, preventing answer sharing between agents.
Task Types
Tasks are designed to be trivial for LLMs, impractical for humans at volume, and deterministically verifiable with zero LLM cost. Each task has a difficulty tier that controls which task pool is available at each difficulty level.
Difficulty Tiers
| Preset | Included Tiers | Description |
|--------|---------------|-------------|
| easy | easy | Only the simplest tasks. Best for smaller models. |
| standard | easy + standard | Balanced mix. The default. |
| hard | easy + standard + hard | All tasks, including multi-step and complex operations. |
Task Reference
| Task Type | Difficulty | Example | Why It Works |
|-----------|------------|---------|--------------|
| reverse | easy | Reverse the string xK9mQ2nL | Fast for LLMs, tedious at speed for humans |
| arraySort | easy | Sort this array and return comma-separated | Mechanical but multi-step |
| pattern | easy | Complete this repeating sequence | Pattern recognition in bulk |
| firstLetters | easy | Concatenate first letter of each word | Extraction across a list |
| interleave | easy | Interleave characters from two strings | Parallel traversal |
| nthWord | standard | What is word 7 of this 10-word list? | Precise indexing under time pressure |
| jsonBuild | standard | Build JSON from these words + rules | Structural reasoning |
| caesarShift | standard | Shift each letter by N positions in the alphabet | Algorithmic, wrapping cipher |
| transform | standard | Apply these 3 transforms to this string | Multi-step string manipulation |
| wordLengths | standard | Return character count of each word | Bulk measurement |
| filterWords | standard | Return words with more than 5 characters, sorted | Filter + sort reasoning |
New task types can be added over time. The server declares supported types in the discovery endpoint. The diversity of task types is itself a defense -- writing a non-LLM solver for all types is more effort than just using an LLM.
Configuration
fishnetAuth({
// Required
secret: string, // HMAC secret for seed generation
adapter: FishnetAuthAdapter, // Your database adapter
// Optional
credentials: {
type: 'api-key' | 'jwt', // Default: 'api-key'
prefix: string, // e.g. 'clawpoker_' -- default: 'ag_'
expiresIn: string, // e.g. '30d' -- default: '7d'
jwtSecret: string, // Required if type is 'jwt'
},
difficulty: 'easy' | 'standard' | 'hard', // Default: 'standard'
taskCount: number, // Default: 5
minCorrect: number, // Default: taskCount (all must be correct)
seedRotationSeconds: number, // Default: 300 (5 min)
taskTypes: string[], // Default: all for selected difficulty
// Callbacks
onVerified: (agent: { name: string; id: string }) => Promise<void>,
onFailed: (info: { name: string; reason: string }) => Promise<void>,
});Configuration Details
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| secret | string | required | HMAC secret for deterministic seed generation |
| adapter | FishnetAuthAdapter | required | Database adapter for storing agents and auth state |
| credentials.type | 'api-key' \| 'jwt' | 'api-key' | Credential format issued on success |
| credentials.prefix | string | 'ag_' | Prefix for generated API keys |
| credentials.expiresIn | string | '7d' | Duration string (e.g. '30d', '12h', '3600s') |
| credentials.jwtSecret | string | -- | Required when credentials.type is 'jwt' |
| difficulty | 'easy' \| 'standard' \| 'hard' | 'standard' | Controls which task tiers are included |
| taskCount | number | 5 | Number of tasks per challenge |
| minCorrect | number | taskCount | Minimum correct answers to pass (must satisfy 1 <= minCorrect <= taskCount) |
| seedRotationSeconds | number | 300 | Seed rotation interval in seconds |
| taskTypes | string[] | all for difficulty | Restrict to specific task types |
| onVerified | function | -- | Callback after successful verification |
| onFailed | function | -- | Callback after failed verification |
Pass Threshold (minCorrect)
By default, an agent must answer all tasks correctly to pass. The minCorrect option lets you relax this:
fishnetAuth({
secret: process.env.FISHNET_AUTH_SECRET!,
adapter: MemoryAdapter(),
taskCount: 5,
minCorrect: 4, // 4 out of 5 is enough
});This is useful when targeting models that occasionally stumble on edge cases but can reliably solve most tasks. The minCorrect value is exposed in the discovery endpoint so agents know the threshold upfront.
Database Adapters
fishnet-auth doesn't own your data. You bring your own database via an adapter.
Supabase
import { SupabaseAdapter } from 'fishnet-auth/adapters/supabase';
SupabaseAdapter(supabaseClient);Redis
import { RedisAdapter } from 'fishnet-auth/adapters/redis';
RedisAdapter(redisClient);Drizzle (Postgres, MySQL, SQLite)
import { DrizzleAdapter } from 'fishnet-auth/adapters/drizzle';
DrizzleAdapter({ db, agentsTable, authLogTable, eq, and });Memory (Development)
import { MemoryAdapter } from 'fishnet-auth/adapters/memory';
MemoryAdapter();
// In-memory store, resets on restart -- for dev/testing onlyBuild Your Own
Implement five methods:
interface FishnetAuthAdapter {
createAgent(agent: AgentRecord): Promise<void>;
getAgentByKey(apiKey: string): Promise<AgentRecord | null>;
revokeAgent(id: string): Promise<void>;
hasCompletedAuth(seed: string, name: string): Promise<boolean>;
markAuthCompleted(seed: string, name: string): Promise<void>;
}Framework Support
Next.js (App Router)
// app/api/agent-auth/[[...fishnet-auth]]/route.ts
import { fishnetAuth } from 'fishnet-auth';
export const { GET, POST } = fishnetAuth({ ... });The fishnetAuth() call returns { GET, POST, engine }. The engine is a FishnetAuthEngine instance you can use directly if needed.
To validate agent tokens in your protected routes:
import { getAgent } from 'fishnet-auth';
export async function POST(req: Request) {
const agent = await getAgent(req);
if (!agent) return Response.json({ error: 'unauthorized' }, { status: 401 });
// agent.id, agent.name, agent.apiKey, agent.createdAt, agent.expiresAt
}If you prefer to avoid module-level global state, use the createGetAgent factory:
import { createGetAgent } from 'fishnet-auth';
const getAgent = createGetAgent({
secret: process.env.FISHNET_AUTH_SECRET!,
adapter: SupabaseAdapter(supabase),
});Hono
import { fishnetAuth } from 'fishnet-auth/hono';
app.route('/api/agent-auth', fishnetAuth({ ... }));Express
import { fishnetAuthRouter } from 'fishnet-auth/express';
app.use('/api/agent-auth', fishnetAuthRouter({ ... }));Generic (Web Standard Request/Response)
import { createGenericHandler } from 'fishnet-auth/generic';
const { handleDiscovery, handleAuth, handleProtect, engine } = createGenericHandler(config);
// Works with Deno, Bun, Cloudflare Workers, Vercel EdgeCore API
For advanced use cases, you can use the core engine and utility functions directly:
import {
FishnetAuthEngine,
generateChallenge,
verifyAnswers,
generateSeed,
isValidSeed,
createSeededRng,
getAllTaskTypes,
getTaskDefinition,
getTaskDefinitions,
} from 'fishnet-auth/core';FishnetAuthEngine
The engine class encapsulates the full auth lifecycle:
const engine = new FishnetAuthEngine(config);
// Get discovery response (includes tasks if agent name provided)
const discovery = engine.getDiscovery('MyBot');
// Authenticate an agent
const result = await engine.authenticate({ name, seed, answers });
// Validate a bearer token
const agent = await engine.validateToken(apiKey);
// Extract bearer token from Authorization header
const token = engine.extractToken(request.headers.get('Authorization'));Pure Functions
Challenge generation and verification are pure, deterministic functions with no side effects:
// Generate tasks for an agent (deterministic given same inputs)
const tasks = generateChallenge(secret, seed, agentName, taskTypes, taskCount, difficulty);
// Verify submitted answers
const result = verifyAnswers(secret, seed, agentName, answers, taskTypes, taskCount, minCorrect, difficulty);
// result: { valid: boolean, expected: number, correct: number, minCorrect: number }Task Registry
// Get all task type names for a difficulty level
const types = getAllTaskTypes('standard'); // ['reverse', 'nthWord', 'jsonBuild', ...]
// Get a single task definition
const def = getTaskDefinition('caesarShift');
// Get filtered task definitions
const defs = getTaskDefinitions(['reverse', 'caesarShift'], 'standard');Performance
fishnet-auth is designed for high-throughput, low-latency environments.
| Metric | Value | |--------|-------| | Auth overhead per agent | ~1-2s (mostly agent-side LLM time) | | Server-side verification | ~0.014ms per challenge | | LLM cost to your server | Zero | | 300 concurrent agents | ~6.7ms total verification time | | State stored during auth | None (seed-based, stateless) |
Seed verification is a pure function -- no database reads, no LLM calls, no external services. The only I/O is the database write after successful verification to store the new agent credential.
Security
Replay prevention: Each (seed, agentName) pair generates unique tasks. Seeds rotate every 5 minutes. Completed (seed, name) pairs are tracked to prevent reuse.
Answer sharing: Agents with different names get different tasks from the same seed, so publishing answers doesn't help other agents.
Non-LLM solvers: The diversity of task types (11 and growing) makes writing a comprehensive non-LLM solver impractical.
Seed predictability: Seeds are HMAC-derived from a server secret and time window. Without the secret, future seeds cannot be predicted.
API key security: API keys are hashed using SHA-256 before storage. Only the hashed values are stored in your database for enhanced security.
Rate limiting: fishnet-auth focuses on authentication logic and does not include built-in rate limiting. For production deployments, implement rate limiting at the infrastructure level using:
- Reverse proxy (nginx, Apache)
- CDN/Edge services (Cloudflare, CloudFront)
- API Gateway (AWS API Gateway, Azure API Management)
- Application middleware (express-rate-limit, fastify-rate-limit)
- Network-level protection (WAF, DDoS protection)
This approach provides better performance and more flexible rate limiting policies than library-level implementations.
Exports
fishnet-auth provides multiple entry points for different use cases:
| Import Path | Contents |
|-------------|----------|
| fishnet-auth | Core engine, types, Next.js adapter (fishnetAuth, getAgent, createGetAgent) |
| fishnet-auth/core | Core engine, types, seed utilities, task registry |
| fishnet-auth/client | AgentAuthClient for agent-side authentication |
| fishnet-auth/nextjs | fishnetAuth, getAgent, createGetAgent |
| fishnet-auth/hono | Hono framework adapter |
| fishnet-auth/express | Express framework adapter |
| fishnet-auth/generic | Web Standard Request/Response handler |
| fishnet-auth/adapters/supabase | Supabase adapter |
| fishnet-auth/adapters/redis | Redis adapter |
| fishnet-auth/adapters/drizzle | Drizzle ORM adapter |
| fishnet-auth/adapters/memory | In-memory adapter (dev/testing) |
Roadmap
- [x] Core: seed generation, task engine, verification
- [x] Adapters: Supabase, Redis, Drizzle, Memory
- [x] Frameworks: Next.js, Hono, Express, Generic
- [x] Client SDK
- [x] Difficulty tiers (easy, standard, hard)
- [x] Configurable pass threshold (
minCorrect) - [ ] Agent reputation / persistent identity across sessions
- [ ] Portable identity across platforms
- [ ] Rate limiting middleware
- [ ] Dashboard / analytics hooks
Philosophy
- You own your data. fishnet-auth is a library, not a service. No hosted backend, no vendor lock-in.
- Zero server-side LLM cost. Challenge generation and verification are pure computation.
- One round trip. Auth completes in a single POST request.
- Framework agnostic. Core logic is pure functions. Framework adapters are thin wrappers.
- Open protocol. The challenge-response spec is public. Anyone can implement a compatible server or client.
Contributing
This is early. The protocol, task types, and adapter interface are all open for discussion. If you're building agent-facing infrastructure, we'd love your input.
License
MIT
