A Next.js scaffold for building applications on ATProto using native ATProto. This project demonstrates authentication, profile management, and hypercert creation on the ATProto network.
- Node.js 20+ (we recommend nvm for version management)
- pnpm package manager (
npm install -g pnpm) - Redis for session & state storage:
- Local development:
docker run -d -p 6379:6379 redis:alpine - Cloud Redis: Upstash, Redis Labs, Railway, etc. (see Environment Configuration)
- Local development:
- A PDS account for testing (e.g., on https://pds-eu-west4.test.certified.app)
# Clone the repository
git clone <repository-url>
cd hypercerts-scaffold
# Install dependencies (we use pnpm for this project)
pnpm install
# Copy environment file and configure
cp .env.example .env.local
# Make sure Redis is running (if using Docker)
docker ps # should show a Redis container running
# Generate and display the key
pnpm run generate-jwk
# Or append directly to .env.local
pnpm run --silent generate-jwk >> .env.local
# Run the development server
pnpm run devOpen http://127.0.0.1:3000 to see the application.
⚠️ OAuth Requirement: You must usehttp://127.0.0.1:3000for local development (notlocalhost).
The app automatically redirectslocalhostto127.0.0.1for RFC 8252 OAuth compliance.
See Localhost Redirect for details.
This scaffold uses native ATProto — all record operations go through @atproto/api directly, with @hypercerts-org/lexicon for type definitions and record validation. There is no SDK wrapper layer.
Issues & Support: Found a bug or have questions? Create an issue and @kzoeps will respond!
| Variable | Description |
|---|---|
NEXT_PUBLIC_BASE_URL |
App base URL. Use http://127.0.0.1:3000 for local dev. Falls back to VERCEL_URL on Vercel. |
ATPROTO_JWK_PRIVATE |
Private JWK (JWKS format) for OAuth client assertion. Generate with pnpm run generate-jwk. |
REDIS_HOST |
Redis server hostname (e.g., localhost for Docker, or cloud Redis host) |
REDIS_PORT |
Redis server port (default: 6379) |
REDIS_PASSWORD |
Redis password. Leave empty for local Docker (no auth). |
NEXT_PUBLIC_PDS_URL |
Personal Data Server URL (e.g., https://pds-eu-west4.test.certified.app) |
| Variable | Description |
|---|---|
NEXT_PUBLIC_EPDS_URL |
ePDS URL for email-based login. When set, enables the Email login tab in the UI. Example: https://epds1.test.certified.app |
NEXT_PUBLIC_EPDS_HANDLE_MODE |
ePDS handle creation mode. Valid values: random, picker, picker-with-random (default). Only used when NEXT_PUBLIC_EPDS_URL is set. |
REDIS_USERNAME |
Redis username. Defaults to default when REDIS_PASSWORD is set. |
NEXT_PUBLIC_HANDLE_RESOLVER |
Handle resolver URL. Defaults to https://bsky.social. |
For local development, you must use 127.0.0.1 instead of localhost:
NEXT_PUBLIC_BASE_URL=http://127.0.0.1:3000This is required for RFC 8252 compliance. The application includes automatic redirect handling - if you access http://localhost:3000, you'll be redirected to http://127.0.0.1:3000.
For development and testing, use these servers:
NEXT_PUBLIC_PDS_URL=https://pds-eu-west4.test.certified.app
# Optional: Enable email login via ePDS
NEXT_PUBLIC_EPDS_URL=https://epds1.test.certified.appIf you need to test with external services (webhooks, mobile apps, etc.), use ngrok:
# Start ngrok
ngrok http 3000
# Update .env.local with your ngrok URL
NEXT_PUBLIC_BASE_URL=https://abc123.ngrok.ioThe ATPROTO_JWK_PRIVATE is a JSON Web Key used for OAuth authentication. Generate one using the included script:
# Install dependencies first
pnpm install
# Generate and display the key
pnpm run generate-jwk
# Or append directly to .env.local
pnpm run --silent generate-jwk >> .env.localThe script outputs the complete environment variable line. You can either copy it manually or append directly to your .env.local file using >>.
┌─────────────────────────────────────────────────────────────────┐
│ Client (Browser) │
│ Login Dialog (Handle │ Email) │ React Query │ Forms │
└──────────────┬──────────────────────────┬───────────────────────┘
│ │
┌──────────▼──────────┐ ┌──────────▼──────────┐
│ /api/oauth/* │ │ /api/oauth/epds/* │
│ (Standard ATProto) │ │ (ePDS Email Login) │
│ NodeOAuthClient │ │ Manual DPoP + PKCE │
└──────────┬──────────┘ └──────────┬───────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────┐
│ Native ATProto (Agent from @atproto/api) │
│ Session Restore │ @hypercerts-org/lexicon │
│ Validation │ CRUD (getRecord, putRecord) │
└──────────┬──────────────────────┬───────────────┘
│ │
┌──────▼──────┐ ┌──────▼──────────┐
│ Redis │ │ PDS / ePDS │
│ Sessions │ │ XRPC calls: │
│ OAuth State │ │ getRecord │
└─────────────┘ │ createRecord │
│ putRecord │
│ deleteRecord │
│ uploadBlob │
└─────────────────┘
Also served: /client-metadata.json and /jwks.json (OAuth metadata endpoints, no auth required).
- PDS (Personal Data Server) stores user data (profiles, hypercerts). Standard ATProto login authenticates against the user's PDS using handle-based OAuth managed by NodeOAuthClient.
- ePDS (Email PDS) is a Certified Auth PDS that supports email-based login with OTP verification. It serves the same role as a standard PDS but uses an email address instead of a handle for authentication. The ePDS flow is enabled when
NEXT_PUBLIC_EPDS_URLis set.
TL;DR: Use http://127.0.0.1:3000 for local development. The app automatically redirects localhost → 127.0.0.1, but your .env.local must use 127.0.0.1 for OAuth to work.
This application automatically redirects requests from localhost to 127.0.0.1 to ensure RFC 8252 compliance for OAuth loopback clients.
RFC 8252 Section 7.3 requires OAuth loopback clients to use IP addresses (127.0.0.1) instead of hostnames (localhost) for security reasons:
- Prevents DNS rebinding attacks: Using an IP address ensures the redirect stays on the local machine
- Consistent OAuth behavior: ATProto PDSs expect IP-based loopback addresses
- Browser security: Some browsers handle
localhostand127.0.0.1differently for security features
Problem: "OAuth callback failed" or "Invalid redirect_uri"
Solution:
- Check that
NEXT_PUBLIC_BASE_URLin.env.localuses127.0.0.1(notlocalhost) - Restart the dev server after changing
.env.local - Clear browser cookies and try again
Problem: Redirect loop after login
Solution:
- Clear browser cookies
- Restart the dev server
- Try in incognito/private browsing mode
- Check that Redis is running (
docker ps)
Problem: Works on 127.0.0.1 but not with ngrok
Solution:
- Update
NEXT_PUBLIC_BASE_URLto your ngrok URL (e.g.,https://abc123.ngrok.io) - Restart the dev server after changing the URL
- Note: ngrok URLs change on each restart unless you have a paid plan with reserved domains
This scaffold uses OAuth 2.0 with DPoP (Demonstrating Proof of Possession) for authentication, implemented via @atproto/oauth-client-node.
Flow:
- User enters their handle (e.g.,
user.example.com) - Application redirects to the ATProto authorization server
- User approves the application
- OAuth callback receives the authorization code
- NodeOAuthClient exchanges code for session credentials (stored in Redis)
- App creates an opaque
sidcookie and storessid -> didmapping in Redis
Requires
NEXT_PUBLIC_EPDS_URLto be set. When configured, an Email tab appears in the login dialog.
- User enters their email address (or leaves it blank for the ePDS to collect it)
- App sends a Pushed Authorization Request (PAR) to the ePDS with a PKCE challenge and DPoP proof
- App stores OAuth state (code verifier + DPoP private key) in Redis
- User is redirected to the ePDS authorization page
- ePDS sends a one-time password (OTP) to the user's email
- User enters the OTP code on the ePDS page
- ePDS redirects back to
/api/oauth/epds/callbackwith an authorization code - App exchanges the code for tokens using DPoP, creates a session in Redis
- User is authenticated — same
sidcookie model as the standard flow
Key technical details:
- DPoP (Demonstrating Proof-of-Possession) uses EC P-256 keys to bind tokens to the client
- PKCE with S256 challenge method prevents authorization code interception
- OAuth state is stored in Redis (not cookies) to avoid cross-site redirect issues
- The ePDS flow supports custom branding (logo, colors) and a custom email template for OTP codes
- When only
NEXT_PUBLIC_PDS_URLis set: the login dialog shows only the Handle tab - When
NEXT_PUBLIC_EPDS_URLis also set: the login dialog shows a pill toggle with Handle and Email tabs - Both flows result in the same session format — downstream code is agnostic to the login method
Use getRepoContext() to get an authenticated agent in server components or API routes:
import { getRepoContext } from "@/lib/repo-context";
export async function GET() {
const ctx = await getRepoContext();
if (!ctx) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
// ctx.userDid - the authenticated user's DID
// ctx.activeDid - currently active profile DID
// ctx.targetDid - the DID this operation targets
// ctx.agent - Agent instance bound to the OAuth session
// Use agent for direct ATProto calls
const result = await ctx.agent.com.atproto.repo.getRecord({
repo: ctx.activeDid,
collection: "app.certified.actor.profile",
rkey: "self",
});
return Response.json(result.data.value);
}Alternative using getAgent():
import { getAgent } from "@/lib/atproto-session";
export async function GET() {
const agent = await getAgent();
if (!agent) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
// agent is an @atproto/api Agent bound to the OAuth session
}The repository context provides access to the authenticated user's agent and DID information.
import { getRepoContext } from "@/lib/repo-context";
export async function GET() {
const ctx = await getRepoContext();
if (!ctx) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
// Access user profile
const profile = await ctx.agent.com.atproto.repo.getRecord({
repo: ctx.activeDid,
collection: "app.certified.actor.profile",
rkey: "self",
});
// Create a hypercert
await ctx.agent.com.atproto.repo.createRecord({
repo: ctx.activeDid,
collection: "app.certified.hypercert",
record: {
title: "My Hypercert",
description: "A certificate of impact",
// ... other fields
},
});
// List hypercerts
const hypercerts = await ctx.agent.com.atproto.repo.listRecords({
repo: ctx.activeDid,
collection: "app.certified.hypercert",
});
return Response.json({
profile: profile.data.value,
hypercerts: hypercerts.data.records,
});
}├── app/
│ ├── api/
│ │ ├── oauth/ # ATProto OAuth (login, callback, logout)
│ │ │ └── epds/ # ePDS email OAuth (login, callback)
│ │ ├── certs/ # Hypercert operations (create, add-attachment, add-location)
│ │ └── profile/ # Profile management (certified + bluesky)
│ ├── client-metadata.json/ # OAuth client metadata endpoint
│ ├── jwks.json/ # Public JWKS endpoint
│ ├── hypercerts/ # Hypercert pages (list, create, [detail])
│ ├── profile/ # Certified profile page
│ └── bsky-profile/ # Bluesky profile page
├── components/ # React components (login dialog, forms, detail views)
│ └── ui/ # shadcn/ui primitives (button, dialog, input, etc.)
├── lib/
│ ├── api/ # Client-side API functions and types
│ ├── config.ts # Centralized app configuration
│ ├── hypercerts-sdk.ts # OAuth client initialization (NodeOAuthClient)
│ ├── redis.ts # Redis client setup
│ ├── redis-state-store.ts # Redis stores (sessions, OAuth state, ePDS state)
│ ├── atproto-session.ts # Server-side session helpers
│ ├── atproto-writes.ts # Shared write utilities (StrongRef resolution, location creation, blob upload)
│ ├── record-validation.ts # Generic lexicon record validation assertion
│ ├── repo-context.ts # Helper to get authenticated Agent + DID context
│ ├── types.ts # TypeScript types, Collections enum, type guards
│ ├── blob-utils.ts # Blob reference to URL resolution for rendering
│ ├── create-actions.ts # Server actions for CRUD operations
│ ├── epds-config.ts # ePDS OAuth endpoint configuration
│ ├── epds-helpers.ts # ePDS PKCE + DPoP utilities
│ └── atproto-branding.ts # OAuth page branding (CSS, logos)
├── providers/ # React providers (QueryClient, auth gating)
├── queries/ # TanStack Query hooks (auth, hypercerts, profile)
├── public/ # Static assets (logos, email template)
└── scripts/ # Utility scripts (JWK generation)
| File | Purpose |
|---|---|
lib/config.ts |
Centralized configuration — base URLs, OAuth client IDs, redirect URIs, scopes |
lib/hypercerts-sdk.ts |
OAuth client initialization with NodeOAuthClient, Redis stores |
lib/redis-state-store.ts |
Three Redis-backed stores: sessions, standard OAuth state, ePDS OAuth state |
lib/repo-context.ts |
Helper to get authenticated Agent + DID context |
lib/atproto-writes.ts |
Shared write utilities (StrongRef resolution, location creation, blob upload) |
lib/record-validation.ts |
Generic lexicon record validation assertion |
lib/types.ts |
TypeScript types, Collections enum, type guards |
lib/blob-utils.ts |
Blob reference to URL resolution for rendering |
lib/epds-config.ts |
Derives ePDS OAuth endpoints (PAR, auth, token) from NEXT_PUBLIC_EPDS_URL |
lib/epds-helpers.ts |
PKCE code verifier/challenge, DPoP key generation and proof creation |
components/login-dialog.tsx |
Dual-mode login UI with Handle/Email pill toggle |
app/client-metadata.json/route.ts |
OAuth client metadata (RFC 7591) — serves client_id, redirect_uris, branding |
- DEVELOPMENT.md - Development guide, contributing guidelines
- Report Issues
- Maintainer: @kzoeps