Zero-Account Auth: Device-Bound Identity + Capability Tokens

8 min readYaseen Khatib · MERN + AI Architect
Cover illustration: Zero-Account Auth: Device-Bound Identity + Capability Tokens

Why “zero-account” and what problem does it solve?

In a zero-account model, the device is the principal. It generates a key pair and the public key, or its thumbprint, is the identity you use everywhere. Authorization comes from signed capability tokens that spell out what the device is allowed to do.

You get rid of passwords and most PII. You also remove the central auth server from the hot path. The result still has strong, auditable security properties.

How do you authenticate devices without an auth server?

Use proof-of-possession. Each device creates a non-extractable key pair locally. Every request carries two artifacts: a DPoP proof (a signed JWT with the HTTP method, target URL, and a fresh jti) and a capability token tied to that key. Resource servers verify the DPoP and check that the capability’s confirmation claim matches the device key thumbprint. No central login step is required.

Core pieces

  • Device key pair (identity): P-256 or Ed25519, with the private key held in a platform keystore or as a non-extractable WebCrypto key.
  • DPoP proof per request: a signed JWT carrying htm/htu/iat/jti to stop replay and bind requests to the device key.
  • Capability token: a signed, scoped token (for example, PASETO v4.public) that encodes allowed actions and lifetime.
  • Stateless verification: services keep the signer’s public key and a small jti replay cache instead of a user database.

How do capability tokens replace user accounts?

Put permissions, resource scopes, rate or usage limits, and expiration into a signed token. Bind it to the device key with a confirmation (cnf) claim. Services only need the issuer’s public key to verify and authorize a request. No per-user record, session, or cookie jar.

  • Format: PASETO v4.public (Ed25519) or Macaroons for attenuation/delegation chains.
  • Claims:
    • sub: device identifier (e.g., JWK thumbprint)
    • cnf: { jkt: base64url(thumbprint) } to bind token to device key
    • cap: list of capabilities [{ act, res, limit?, nbf?, exp? }]
    • iat/exp/jti: temporal and anti-replay metadata

Example capability list:

  • act=read, res=/notes/*
  • act=write, res=/notes/{noteId}
  • limit=100 writes/24h (enforced server-side by metering on jti or sub)

Bootstrapping without accounts

You still need an initial trust handshake, but not a login.

  • Local-first: app generates a key pair on first run and displays a pairing code. A one-shot provisioning endpoint mints the first capability (for example, from a purchase receipt, license, or admin console) scoped to the device’s jkt.
  • Recurring: the device refreshes capabilities using a narrow “refresh” capability, still bound to the device. No username or password, no session cookies.

Client: device key generation and DPoP

Generate a non-extractable key and make DPoP-bound API calls. In a browser, use WebCrypto. In native apps, use Keychain/Keystore.

// browser-client.ts
import { exportJWK, calculateJwkThumbprint, SignJWT } from 'jose';

// 1) Generate a non-extractable ES256 keypair and persist via IndexedDB (omitted for brevity)
const { publicKey, privateKey } = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' },
  false, // non-extractable private key
  ['sign', 'verify']
);

const pubJwk = await exportJWK(publicKey as CryptoKey);
const jkt = await calculateJwkThumbprint(pubJwk, 'sha256');

// 2) DPoP signer
async function dpopProof(method: string, url: string) {
  return await new SignJWT({ htm: method, htu: url, jti: crypto.randomUUID() })
    .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: pubJwk })
    .setIssuedAt()
    .sign(privateKey as CryptoKey);
}

// 3) Call API with capability token bound to this key
export async function callApi(capToken: string, method: string, url: string, body?: any) {
  const proof = await dpopProof(method, url);
  const res = await fetch(url, {
    method,
    headers: {
      'Authorization': `Bearer ${capToken}`,
      'DPoP': proof,
      'Content-Type': 'application/json'
    },
    body: body ? JSON.stringify(body) : undefined
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

Notes

  • The privateKey is non-extractable, so page malware can’t lift it.
  • DPoP carries htm/htu/jti/iat; a replay to a different URL or method fails.
  • capToken is issued out of band (provisioning or refresh) and must include cnf.jkt = jkt.

Server: verifying DPoP and PASETO capabilities

The resource server runs two separate checks: DPoP validity with replay protection, then capability verification and scope enforcement.

// server-auth.ts
import { decodeProtectedHeader, importJWK, jwtVerify, calculateJwkThumbprint } from 'jose';
import { V4 } from 'paseto';
import express from 'express';
import Redis from 'ioredis';

const app = express();
app.use(express.json());

// PASETO issuer keys (provisioned at deploy time)
// In production, load from HSM or env and rotate.
const { secretKey, publicKey } = await V4.generateKey('public');

const redis = new Redis(process.env.REDIS_URL!);

// Authorization middleware
app.use(async (req, res, next) => {
  try {
    const dpop = req.header('DPoP');
    const auth = req.header('Authorization');
    if (!dpop || !auth?.startsWith('Bearer ')) return res.status(401).end();
    const capToken = auth.slice('Bearer '.length);

    // 1) Verify DPoP proof
    const hdr = decodeProtectedHeader(dpop);
    if (hdr.typ !== 'dpop+jwt' || !hdr.jwk) return res.status(401).end();
    const dpopKey = await importJWK(hdr.jwk as any, hdr.alg as any);
    const { payload } = await jwtVerify(dpop, dpopKey, {
      typ: 'dpop+jwt',
      clockTolerance: '5s'
    });

    const method = (payload.htm as string || '').toUpperCase();
    const url = payload.htu as string;
    if (method !== req.method.toUpperCase()) return res.status(401).end();
    const expectedUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
    if (!url || !expectedUrl.startsWith(url)) return res.status(401).end();

    // Anti-replay: store jti with short TTL
    const jti = payload.jti as string;
    if (!jti) return res.status(401).end();
    const replay = await redis.setnx(`dpop:jti:${jti}`, '1');
    if (!replay) return res.status(401).send('replay');
    await redis.expire(`dpop:jti:${jti}`, 10); // seconds window

    const jkt = await calculateJwkThumbprint(hdr.jwk as any, 'sha256');

    // 2) Verify capability token (PASETO v4.public)
    const cap = await V4.verify(capToken, publicKey);

    // cnf binding check
    if (!cap.cnf || cap.cnf.jkt !== jkt) return res.status(401).send('key mismatch');

    // Temporal checks (paseto verifies exp/nbf if present, still validate policy)
    if (cap.exp && Date.now() / 1000 >= cap.exp) return res.status(401).send('expired');

    // Scope enforcement
    if (!allows(cap.cap, req.method, req.path)) return res.status(403).send('forbidden');

    // Optional: per-capability metering (rate/limit)
    // await enforceLimits(cap.sub, cap.jti, cap.cap)

    // Attach identity to request
    (req as any).device = { sub: cap.sub, jkt };
    next();
  } catch (e) {
    console.error(e);
    res.status(401).end();
  }
});

function allows(caps: any[], method: string, path: string) {
  // Minimal example: method+path prefix match
  return (caps || []).some(c =>
    (!c.act || c.act.toUpperCase() === method.toUpperCase()) &&
    pathMatch(c.res, path)
  );
}

function pathMatch(pattern: string, path: string) {
  // naive: '/notes/*' => prefix match
  if (!pattern) return false;
  if (pattern.endsWith('/*')) return path.startsWith(pattern.slice(0, -1));
  return pattern === path;
}

app.get('/notes/*', (req, res) => {
  res.json({ ok: true, device: (req as any).device });
});

app.listen(3000);

Key points

  • The DPoP header carries the public JWK; the server verifies it and computes the thumbprint.
  • Capability verification is stateless, only the publicKey is required.
  • cnf.jkt binds the token to the device, so a stolen token is useless without the private key.

Issuing capability tokens (no auth server required)

Any internal service holding the signing key can mint capabilities. Do it during provisioning, purchase validation, or admin workflows.

// issuer.ts
import { V4 } from 'paseto';
import { randomUUID } from 'crypto';

export async function issueCapability(secretKey: any, deviceJkt: string, caps: any[], ttlSec = 3600) {
  const now = Math.floor(Date.now() / 1000);
  const payload = {
    sub: `device:${deviceJkt}`,
    cnf: { jkt: deviceJkt },
    cap: caps,
    iat: now,
    exp: now + ttlSec,
    jti: randomUUID(),
    iss: 'cap-issuer:v1',
    aud: 'api'
  };
  return await V4.sign(payload, secretKey);
}

// Example cap set: read-only notes
await issueCapability(secretKey, 'kQ2…thumbprint…', [
  { act: 'GET', res: '/notes/*' }
], 900);

How do you revoke a leaked capability token without a DB?

Favor lifecycle controls over stateful revocation. Issue short-lived tokens, bind them to device keys with cnf.jkt plus DPoP, rotate signing keys on a schedule, and keep a small jti denylist only for emergencies. For long-lived tokens, include a refresh capability you can cut off by rotating a signer or revoking a cohort.

Practical revocation strategies

  • Short TTLs everywhere (5–15 minutes typical; seconds for high-value actions).
  • Rotate issuer keys regularly; invalidate cohorts by key ID (kid) rollover.
  • Maintain a Redis-backed jti denylist only for incident response; TTL entries to auto-expire.
  • Attenuate via nested capabilities or macaroons for delegations; revoke by refusing to mint downstream.

Macaroons vs PASETO

Aspect PASETO v4.public Macaroons
Simplicity Single signature, flat claims Attenuation via chained caveats
Delegation Explicit via minting new token Natural via third-party caveats
Verification Public key verify Shared root key + caveat verify
Fit here Great for stateless caps Great for delegation-heavy flows

For most zero-account APIs, PASETO v4.public with cnf binding and DPoP keeps things simple and robust. Use macaroons when you need on-chain attenuation and delegation proofs across services.

Threat model and mitigations

  • Token theft: mitigated by cnf.jkt plus DPoP; proofs are replay-protected by a jti cache and htm/htu binding.
  • Device compromise: out of scope; reduce blast radius with least-privilege capabilities and short TTLs.
  • Phishing: no passwords or sessions, which sharply reduces impact.
  • MITM: require HTTPS and HSTS; DPoP helps spot target URL tampering.
  • Key exfiltration (browser): make the private key non-extractable; prefer platform keystores in native apps.

Operational checklist

  • Rotate issuer keys and distribute their public half to all services.
  • Enforce DPoP on every protected endpoint and run a small jti replay cache.
  • Keep capability TTLs short; implement a refresh flow with a narrow-scoped refresh capability.
  • Log cap.jti, sub, and authorization decisions for audits.
  • Use deterministic resource matching and HTTP method checks in capability evaluation.

What about multi-device and recovery?

Use delegation. A primary device issues a short-lived delegation capability to the new device’s jkt via QR or pairing code. The server then grants that device first-party capabilities. For recovery, rely on out-of-band ownership proofs—such as a purchase receipt or an emailed link that mints a new device capability—instead of a persistent account.

MERN integration notes

  • React: hydrate app state from capabilities, no cookies. Use SWR/React Query and attach DPoP to each request.
  • Node/Express: middleware as shown; add route-level capability specs so they can be checked statically.
  • MongoDB: index audit logs by cap.jti and sub for incident triage. Avoid PII storage.
  • Next.js/SSR: don’t embed private keys; sign on the client or via server actions tied to device-bound refresh flows.

Closing thoughts

Zero-account means the device carries its identity and its rights. Pair capability tokens with DPoP, keep TTLs short, and bind everything to the device key. You get stateless, least-privilege authorization with less operational load than a traditional auth server, and a clear path to delegation and rotation as you grow.

Need an engineer who can build this?

I'm Yaseen Khatib — a Senior Full-Stack AI Engineer (MERN + TypeScript) who ships production AI systems solo. Open to senior and lead roles, remote or on-site.