A modern, image-based CAPTCHA library for Go with cryptographic security and Redis-backed session storage.
Catchya generates interactive image-based CAPTCHA challenges where users select images matching a category (cars, traffic lights, buses, bicycles, crosswalks). Each challenge is cryptographically signed, stored in Redis with TTL expiration, and can only be verified once.
%%{init: {'theme':'dark', 'themeVariables': {'fontSize':'16px'}}}%%
flowchart TB
Client["Client Browser"]
subgraph Server["Echo HTTP Server"]
direction TB
Handler["Challenge Handler<br/><small>HTTP Request Processing</small>"]
subgraph Service["Catchya Service"]
direction LR
Generator["Challenge Generator<br/><small>Grid & Image Composition</small>"]
Verifier["Verifier<br/><small>Answer Validation</small>"]
end
subgraph Components["Core Components"]
direction TB
Crypto["Crypto Layer<br/><small>HMAC-SHA256 Signing</small><br/><small>SHA256 Hashing</small>"]
Storage["Storage Layer<br/><small>Redis with TTL</small>"]
ImageLoader["Image Loader<br/><small>Unsplash API / Local</small>"]
Renderer["Grid Renderer<br/><small>PNG Compositor</small>"]
end
end
Client ==>|"1. POST /api/challenge<br/>{grid_size: 3}"| Handler
Handler --> Generator
Generator -.->|Generate ID| Crypto
Generator -.->|Store Challenge| Storage
Generator -.->|Fetch Images| ImageLoader
Generator -.->|Compose Grid| Renderer
Handler ==>|"2. Challenge ID + Image"| Client
Client ==>|"3. POST /api/verify<br/>{id, selection: [0,2,5,7]}"| Handler
Handler --> Verifier
Verifier -.->|Verify Hash| Crypto
Verifier -.->|Get/Update Challenge| Storage
Handler ==>|"4. success: true/false"| Client
classDef clientStyle fill:#1e3a5f,stroke:#4a7ba7,stroke-width:3px,color:#ffffff
classDef serverStyle fill:#1a1a2e,stroke:#2d2d44,stroke-width:2px,color:#e0e0e0
classDef serviceStyle fill:#2a2d3a,stroke:#3a3f5c,stroke-width:2px,color:#e0e0e0
classDef componentStyle fill:#363b4a,stroke:#4a5568,stroke-width:2px,color:#e0e0e0
classDef cryptoStyle fill:#3d1f3d,stroke:#6b3d6b,stroke-width:2px,color:#ffffff
classDef storageStyle fill:#1f3d3d,stroke:#3d6b6b,stroke-width:2px,color:#ffffff
class Client clientStyle
class Server serverStyle
class Service serviceStyle
class Components componentStyle
class Handler,Generator,Verifier serviceStyle
class Crypto cryptoStyle
class Storage storageStyle
class ImageLoader,Renderer componentStyle
sequenceDiagram
participant C as Client
participant H as Handler
participant G as Generator
participant L as ImageLoader
participant R as Renderer
participant Cry as Crypto
participant S as Redis
C->>H: POST /api/challenge {grid_size: 3}
H->>G: Generate(opts)
G->>G: Select random category
alt Unsplash API Key Set
G->>L: Load images from Unsplash API
else No API Key
G->>L: Load local/placeholder images
end
G->>G: Generate grid (3x3, 4x4, 5x5)
Note over G: Randomly position target images<br/>Fill remaining with distractors
G->>R: Render(images)
R-->>G: Composite PNG image
G->>Cry: GenerateChallengeID()
Cry-->>G: HMAC-SHA256 signed ID
G->>Cry: HashAnswer(correctCells)
Cry-->>G: SHA256 hash
G->>S: Store challenge data (TTL: 5min)
Note over S: Key: catchya:{id}<br/>Data: {category, correctCells,<br/>answerHash, expiresAt, used}
H-->>C: {challenge_id, category, grid_size, prompt}
C->>H: GET /api/image/{id}
H-->>C: PNG image
sequenceDiagram
participant C as Client
participant H as Handler
participant V as Verifier
participant Cry as Crypto
participant S as Redis
C->>H: POST /api/verify {id, selection: [0,2,5,7]}
H->>V: Verify(challengeID, selection)
V->>S: Get challenge data
alt Challenge not found
S-->>V: Error
V-->>H: "Challenge expired or invalid"
H-->>C: {success: false}
end
V->>V: Check if already used
alt Already used
V-->>H: "Challenge already used"
H-->>C: {success: false}
end
V->>V: Check expiration time
alt Expired
V-->>H: "Challenge expired"
H-->>C: {success: false}
end
V->>V: Count correct selections
Note over V: 70% threshold + max 1 false positive
alt Pass (≥70% correct, ≤1 wrong)
V->>S: Mark challenge as used
V-->>H: true
H-->>C: {success: true}
else Fail
V-->>H: false
H-->>C: {success: false}
end
- HMAC-SHA256 signed with server secret key
- Contains timestamp + 16 random bytes
- Base64-URL encoded for safe transmission
- Prevents forgery, tampering, and ID prediction
- Answers stored as SHA256 hashes in Redis
- Never stored in plaintext
- Prevents answer leakage from storage layer
- Even database compromise doesn't reveal answers
- Challenges marked as
used: trueafter first verification - Cannot be replayed or reused
- Prevents answer sharing between attempts
- Redis TTL expires challenges (default: 5 minutes)
- Automatic cleanup of stale challenges
- No manual garbage collection needed
- Users need ≥70% correct selections
- Allows up to 1 false positive (wrong selection)
- Balances security with usability
- Example: 4 correct cells → need 3+ right, max 1 wrong
- Go 1.23+
- Docker & Docker Compose (for Redis)
- (Optional) Unsplash API key for real images
- Clone and setup:
git clone <repo>
cd captcha_lib
go mod tidy- Start Redis:
docker-compose up -d- Run server:
go run cmd/catchya/main.go- Open demo: http://localhost:8080/demo/index.html
To use real images from Unsplash instead of placeholders:
- Get API key from https://unsplash.com/developers
- Create
.envfile:
UNSPLASH_API_KEY=your_access_key_here- Run server (automatically loads
.env):
go run cmd/catchya/main.goOr use environment variable directly:
# Windows CMD
set UNSPLASH_API_KEY=your_key
go run cmd/catchya/main.go
# PowerShell
$env:UNSPLASH_API_KEY="your_key"
go run cmd/catchya/main.go
# Unix/Linux/macOS
export UNSPLASH_API_KEY=your_key
go run cmd/catchya/main.goPOST /api/challenge
Content-Type: application/json
{
"grid_size": 3
}Response:
{
"challenge_id": "dGltZXN0YW1wOnJhbmRvbTpzaWduYXR1cmU=",
"category": "cars",
"grid_size": 3,
"instruction": "Select all squares with cars"
}GET /api/image/:challenge_idReturns: PNG image (binary)
POST /api/verify
Content-Type: application/json
{
"challenge_id": "dGltZXN0YW1wOnJhbmRvbTpzaWduYXR1cmU=",
"selection": [0, 2, 5, 7]
}Response:
{
"success": true
}Environment variables:
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
Server port |
REDIS_ADDR |
localhost:6379 |
Redis address |
REDIS_PASSWORD |
"" |
Redis password |
SECRET_KEY |
catchya-secret-key-change-in-production |
HMAC signing key |
DATASET_PATH |
"" |
Local image dataset path |
UNSPLASH_API_KEY |
"" |
Unsplash API access key |
catchya/
├── cmd/
│ └── catchya/
│ └── main.go # Server entry point
├── server/
│ ├── config.go # Configuration loader
│ ├── server.go # Echo server setup
│ └── handlers/
│ └── challenge.go # HTTP handlers
├── crypto/
│ ├── crypto.go # HMAC & hashing
│ └── crypto_test.go
├── images/
│ ├── loader.go # Local image loader
│ ├── api_loader.go # Unsplash API loader
│ ├── generator.go # Placeholder generator
│ └── processor.go # Image processing
├── render/
│ ├── grid.go # Grid composition
│ └── grid_test.go
├── storage/
│ ├── storage.go # Storage interface
│ ├── redis.go # Redis implementation
│ └── redis_test.go
├── demo/
│ ├── index.html # Demo page
│ ├── style.css
│ └── app.js
├── types.go # Core types
├── challenge.go # Challenge generation
├── verify.go # Verification logic
├── captcha.go # Main service
└── docker-compose.yml # Redis container
package main
import (
"github.com/pixperk/catchya"
)
func main() {
service, err := catchya.New(catchya.Config{
RedisAddr: "localhost:6379",
SecretKey: "your-secret-key",
UnsplashAPIKey: "your-unsplash-key", // Optional
})
if err != nil {
panic(err)
}
defer service.Close()
// Generate challenge
challenge, err := service.Generate(catchya.Options{
GridSize: 3,
Category: "cars",
TargetCount: 4,
CellSize: 200,
})
if err != nil {
panic(err)
}
// Verify user selection
success, err := service.Verify(challenge.ID, []int{0, 2, 5, 7})
if err != nil {
panic(err)
}
if success {
println("✓ CAPTCHA verified!")
}
}Run all tests:
go test ./...Run with coverage:
go test -cover ./...Run specific package:
go test ./crypto
go test ./storage
go test ./renderSupported categories:
cars- Automobiles, vehiclestraffic_lights- Traffic signalsbuses- Public transport busesbicycles- Bikes and cyclingcrosswalks- Pedestrian crossings
- Add to
images/generator.goplaceholder generation - Add to
images/loader.gocategory list - Add to
images/api_loader.goquery mapping
Implement the ImageLoader interface:
type ImageLoader interface {
Load() error
GetCategories() []string
GetImages(category string) ([][]byte, error)
GetDistractorImages(excludeCategory string) [][]byte
GetRandomImages(category string, count int) ([][]byte, error)
}Implement the Storage interface:
type Storage interface {
Save(challengeID string, data ChallengeData, ttl time.Duration) error
Get(challengeID string) (*ChallengeData, error)
Update(challengeID string, data ChallengeData) error
Delete(challengeID string) error
Close() error
}- Challenge generation: ~100-200ms (with API), ~10-20ms (local)
- Verification: ~5-10ms
- Redis operations: ~1-5ms
- Grid rendering: ~50-100ms (3x3 grid, 200px cells)
MIT
Contributions welcome! Please open an issue or PR.
Built with:
- Echo - HTTP framework
- go-redis - Redis client
- Unsplash API - Image source