Sentinel
Passkey Delegated MFA
How delegated passkey authentication works with Sentinel/IDP, why to use it, and how to integrate it securely with backend relay.
Passkey Delegated MFA
Sentinel allows you to offer passkey authentication (WebAuthn) to your users through Donutwork IDP while keeping your API key server-side only.
This is the recommended production model:
- Browser uses WebAuthn and your frontend SDK logic.
- Browser calls your backend endpoints.
- Your backend calls Donutwork S2S (
https://api.hub.donutwork.com) with Bearer API key.
Why Passkeys
- Strong phishing resistance compared to password + OTP.
- Better UX: biometric or device unlock instead of manual code typing.
- Lower OTP friction for high-frequency access.
- Hardware-backed credentials where supported by platform.
Security Benefits in Sentinel
- Adaptive flow: Sentinel advisory can recommend
passkeyas preferred factor on higher risk. - Device-bound auth: credential is tied to authenticator/device.
- Centralized eventing: passkey registration/auth events are emitted and traceable.
- Coexistence with OTP: you can keep OTP fallback while rolling out passkeys progressively.
End-to-End Flow
Registration
- Frontend asks your backend for registration options.
- Backend resolves authenticated user in your DB and loads
donutwork_external_id. - Backend calls Donutwork:
POST /2026-02-01/idp/{externalUserId}/passkey/options.json
- Frontend runs
navigator.credentials.create(...). - Frontend sends credential payload to your backend.
- Backend confirms on Donutwork:
POST /2026-02-01/idp/{externalUserId}/passkey/confirm.json
Authentication
- Frontend asks your backend for challenge.
- Backend calls Donutwork:
POST /2026-02-01/idp/passkey/challenge.json
- Frontend runs
navigator.credentials.get(...). - Frontend sends assertion to your backend.
- Backend verifies on Donutwork:
POST /2026-02-01/idp/passkey/verify.json
Data Model Recommendation
Use internal identity as source of truth and map it to Donutwork external user id.
users table
- id (internal PK)
- email (unique)
- password_hash
- donutwork_external_id
- statusNever trust passkey.id from browser for ownership checks.
Always resolve externalUserId server-side from authenticated session/token.
Backend Relay Example (Node.js / Express)
import express from 'express';
import session from 'express-session';
import fetch from 'node-fetch';
const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
const DONUTWORK_API_BASE = 'https://api.hub.donutwork.com';
const DONUTWORK_API_KEY = process.env.DONUTWORK_API_KEY;
// Mock DB adapter (replace with real SQL/ORM)
const db = {
users: {
async findByEmail(email) {
return email
? { id: 42, email, donutwork_external_id: 'ORG_USR_9921_AFG', status: 'active' }
: null;
}
}
};
async function donutwork(path, method, body) {
const res = await fetch(`${DONUTWORK_API_BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${DONUTWORK_API_KEY}`,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
});
const text = await res.text();
const payload = text ? JSON.parse(text) : {};
if (!res.ok) {
const err = new Error(payload.error || `Donutwork ${res.status}`);
err.status = res.status;
err.payload = payload;
throw err;
}
return payload;
}
async function resolveAuthenticatedUser(req) {
const email = req.session?.userEmail;
if (!email) {
const err = new Error('Unauthorized');
err.status = 401;
throw err;
}
const user = await db.users.findByEmail(email);
if (!user || user.status !== 'active') {
const err = new Error('User inactive or not found');
err.status = 403;
throw err;
}
if (!user.donutwork_external_id) {
const err = new Error('User not linked to Donutwork IDP');
err.status = 412;
throw err;
}
return user;
}
app.post('/api/passkey/auth/challenge', async (req, res) => {
try {
const user = await resolveAuthenticatedUser(req);
const out = await donutwork('/2026-02-01/idp/passkey/challenge.json', 'POST', {
passkey: { id: user.donutwork_external_id }
});
res.json(out);
} catch (e) {
res.status(e.status || 500).json(e.payload || { error: e.message });
}
});Backend Relay Example (Django)
# models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
donutwork_external_id = models.CharField(max_length=128, blank=True, null=True)# views_passkey.py
import json
import os
import requests
from typing import Optional, Dict, Any
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_protect
DONUTWORK_API_BASE = "https://api.hub.donutwork.com"
DONUTWORK_API_KEY = os.environ["DONUTWORK_API_KEY"]
def parse_json_body(request):
try:
raw = request.body.decode("utf-8") if request.body else "{}"
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def donutwork_call(path: str, method: str = "POST", payload: Optional[Dict[str, Any]] = None):
response = requests.request(
method=method,
url=f"{DONUTWORK_API_BASE}{path}",
headers={
"Authorization": f"Bearer {DONUTWORK_API_KEY}",
"Content-Type": "application/json",
},
json=payload or {},
timeout=15,
)
data = response.json() if response.text else {}
if not (200 <= response.status_code < 300):
return None, JsonResponse(data or {"error": "Donutwork API error"}, status=response.status_code)
return data, None
@login_required
@csrf_protect
@require_http_methods(["POST"])
def passkey_auth_verify(request):
if not request.user.is_active:
return JsonResponse({"error": "User inactive"}, status=403)
if not request.user.donutwork_external_id:
return JsonResponse({"error": "User not linked to Donutwork IDP"}, status=412)
body = parse_json_body(request)
passkey = body.get("passkey", {})
data, err = donutwork_call(
"/2026-02-01/idp/passkey/verify.json",
"POST",
{"passkey": {
"id": request.user.donutwork_external_id,
"challenge_id": passkey.get("challenge_id"),
"payload": passkey.get("payload"),
"device": passkey.get("device", request.META.get("HTTP_USER_AGENT", "")),
}},
)
return err or JsonResponse(data)Backend Relay Example (PHP + Session + PDO)
<?php
function current_user_from_session(PDO $pdo): array {
if (empty($_SESSION['user_email'])) {
throw new RuntimeException('Unauthorized', 401);
}
$stmt = $pdo->prepare('SELECT id, email, donutwork_external_id, status FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $_SESSION['user_email']]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user || $user['status'] !== 'active') {
throw new RuntimeException('User inactive or not found', 403);
}
if (empty($user['donutwork_external_id'])) {
throw new RuntimeException('User not linked to Donutwork IDP', 412);
}
return $user;
}
function donutwork_call(string $method, string $path, array $body = []): array {
$baseUrl = 'https://api.hub.donutwork.com';
$apiKey = $_ENV['DONUTWORK_API_KEY'];
$ch = curl_init($baseUrl . $path);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json'
]);
if (!empty($body)) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$payload = json_decode($raw ?: '{}', true);
if ($status < 200 || $status >= 300) {
throw new RuntimeException($payload['error'] ?? 'Donutwork API error', $status);
}
return is_array($payload) ? $payload : [];
}Deep Dive & API Endpoints
For raw endpoint schema and payloads, see API reference: