Skip to main content

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

  1. Your app calls requestAsyncAttestation(), which POSTs a new auth request to your relay endpoint.
  2. The user's extension polls your relay endpoint every 5 minutes via a chrome.alarms background job.
  3. 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/ack with no user interaction.
    • If no delegation grant exists: the extension opens a popup and prompts the user for consent.
  4. Your app polls /api/mwen/relay/:requestId until 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).

MethodPathPurpose
POST/api/mwen/relayReceive a new attestation request from requestAsyncAttestation()
GET/api/mwen/relay/:requestIdExtension polls for status; your app polls for the result
POST/api/mwen/relay/ackExtension 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 relayPath in MwenClientConfig to match your endpoint path.