Async Attestations & Relay
The relay protocol allows your app to request a fresh credential from the user's wallet asynchronously — when the user is not actively browsing your site. This is useful for periodic re-verification, background claim refreshes, or any flow where the user needs to provide updated credentials without an immediate interaction.
How it works
- Your app calls
requestAsyncAttestation(), which POSTs a new auth request to your relay endpoint. - The user's extension polls your relay endpoint every 5 minutes via a
chrome.alarmsbackground job. - When the extension finds a pending request:
- If a delegation grant covers the requested scopes: the request is fulfilled silently and the credential is delivered to
/api/mwen/relay/ackwith no user interaction. - If no delegation grant exists: the extension opens a popup and prompts the user for consent.
- If a delegation grant covers the requested scopes: the request is fulfilled silently and the credential is delivered to
- Your app polls
/api/mwen/relay/:requestIduntil the status changes to"approved"or"denied".
Relay endpoints
You must implement these three endpoints in your application. The SDK will POST to relayPath (default: /api/mwen/relay).
| Method | Path | Purpose |
|---|---|---|
POST | /api/mwen/relay | Receive a new attestation request from requestAsyncAttestation() |
GET | /api/mwen/relay/:requestId | Extension polls for status; your app polls for the result |
POST | /api/mwen/relay/ack | Extension delivers the completed credential or an error |
Relay store
The reference implementation (apps/consumer-app/src/lib/relay-store.ts) uses an in-memory Map pinned to globalThis. This is sufficient for development but will not survive process restarts and does not work across multiple server instances.
For production, replace the in-memory store with Redis, PostgreSQL, or SQLite.
// src/lib/relay-store.ts (reference — replace for production)
interface RelayEntry {
requestId: string;
appIdentity: string;
authRequest: unknown;
expiresAt: number;
status: 'pending' | 'approved' | 'denied';
credential?: string;
error?: string;
}
// In-memory store — NOT suitable for production
const store = new Map<string, RelayEntry>();
export function setRelayEntry(entry: RelayEntry): void {
store.set(entry.requestId, entry);
}
export function getRelayEntry(requestId: string): RelayEntry | undefined {
return store.get(requestId);
}
export function updateRelayEntry(
requestId: string,
update: Partial<RelayEntry>
): void {
const entry = store.get(requestId);
if (entry) store.set(requestId, { ...entry, ...update });
}
Implementing the relay endpoints
POST /api/mwen/relay
Receives the relay request from requestAsyncAttestation():
// app/api/mwen/relay/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { setRelayEntry } from '@/lib/relay-store';
export async function POST(req: NextRequest) {
const body = await req.json();
const { request_id, app_identity, auth_request, expires_at } = body;
if (!request_id || !app_identity || !auth_request) {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
setRelayEntry({
requestId: request_id,
appIdentity: app_identity,
authRequest: auth_request,
expiresAt: expires_at ?? Date.now() + 24 * 60 * 60 * 1000,
status: 'pending',
});
return NextResponse.json({ request_id, status: 'pending' });
}
GET /api/mwen/relay/:requestId
Used by both the extension (to fetch pending requests) and your app (to poll for completion):
// app/api/mwen/relay/[requestId]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getRelayEntry } from '@/lib/relay-store';
export async function GET(
_req: NextRequest,
{ params }: { params: { requestId: string } }
) {
const entry = getRelayEntry(params.requestId);
if (!entry) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (Date.now() > entry.expiresAt) {
return NextResponse.json({ error: 'Request expired' }, { status: 410 });
}
return NextResponse.json({
request_id: entry.requestId,
status: entry.status,
auth_request: entry.authRequest, // returned to extension for processing
credential: entry.credential, // returned to your app when approved
error: entry.error,
});
}
POST /api/mwen/relay/ack
The extension calls this to deliver the completed credential (or a denial):
// app/api/mwen/relay/ack/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { updateRelayEntry } from '@/lib/relay-store';
export async function POST(req: NextRequest) {
const { request_id, credential, error } = await req.json();
if (!request_id) {
return NextResponse.json({ error: 'Missing request_id' }, { status: 400 });
}
if (credential) {
updateRelayEntry(request_id, { status: 'approved', credential });
} else {
updateRelayEntry(request_id, { status: 'denied', error: error ?? 'denied' });
}
return NextResponse.json({ ok: true });
}
Requesting an async attestation
'use client';
import { useMwen } from '@mwen/js-sdk/react';
export default function RequestAttestationButton() {
const { requestAsyncAttestation, session } = useMwen();
async function handleRequest() {
if (!session) return;
const requestId = await requestAsyncAttestation();
console.log('Queued relay request:', requestId);
// Store requestId, then poll /api/mwen/relay/:requestId
}
return <button onClick={handleRequest}>Request updated credentials</button>;
}
Polling for the result
async function pollForCredential(requestId: string): Promise<string | null> {
const maxAttempts = 60; // 30 minutes at 30-second intervals
const intervalMs = 30_000;
for (let i = 0; i < maxAttempts; i++) {
await new Promise(r => setTimeout(r, intervalMs));
const res = await fetch(`/api/mwen/relay/${requestId}`);
const data = await res.json();
if (data.status === 'approved') return data.credential;
if (data.status === 'denied') return null;
// status === 'pending' → keep polling
}
return null; // timed out
}
Extension polling schedule
The extension's mwen-relay-poller alarm fires every 5 minutes. It iterates connected apps that have a relay_endpoint set and polls each for pending requests. Exponential backoff is applied on consecutive empty polls (no pending requests).
The user may not be actively browsing when the alarm fires — the service worker runs in the background. For silent auto-approval via delegation grant, no user interaction is required. For requests requiring consent, the extension opens a popup to prompt the user.
Production checklist
- Replace in-memory relay store with Redis, PostgreSQL, or SQLite.
- Add TTL-based cleanup for expired relay entries.
- Add authentication/rate limiting on relay endpoints if needed.
- Set
relayPathinMwenClientConfigto match your endpoint path.