Skip to main content

Verifying Credentials Server-Side

mwen.io credentials are self-contained and verifiable locally — no call back to mwen.io is required. This page covers server-side verification using the SDK utility and the raw verification steps for advanced use.


Using verifyAccessTokenFromHeader()

The simplest server-side verification path. Use this in any API route that requires an authenticated user.

import { verifyAccessTokenFromHeader } from '@mwen/js-sdk/server';

// Next.js App Router example
export async function GET(req: Request) {
const result = await verifyAccessTokenFromHeader(
req.headers.get('authorization') ?? '',
'myapp.com' // your clientId
);

if (!result.valid) {
return Response.json({ error: result.error ?? 'Unauthorized' }, { status: 401 });
}

const userId = result.payload!.sub; // did:jwk — stable user identifier
// ... handle authenticated request
}

What it verifies

  1. Extracts the Bearer token from the Authorization header.
  2. Decodes the JWT header and extracts the kid claim (a did:jwk URI).
  3. Self-resolves the did:jwk to obtain the P-256 public key — no network request.
  4. Verifies the ES256 signature using crypto.subtle.verify().
  5. Checks exp (not expired) and aud (matches your clientId).

Return value

// On success
{ valid: true, payload: { sub, iss, aud, iat, exp, scope } }

// On failure
{ valid: false, error: 'Token expired' }

Manual SD-JWT verification

For advanced use cases — or if you want to verify the full VP without the SDK — here are the raw steps. You can use any standards-compliant JWT library (jose, jsonwebtoken, etc.).

Step 1: Extract the Bearer token

const authHeader = req.headers.get('authorization') ?? '';
const token = authHeader.startsWith('Bearer ')
? authHeader.slice(7).trim()
: null;
if (!token) throw new Error('Missing token');

Step 2: Decode did:jwk → P-256 public key

// The JWT header's `kid` is "did:jwk:<base64url-JWK>#0"
const header = JSON.parse(atob(token.split('.')[0]));
const did = header.kid.replace('#0', ''); // "did:jwk:<base64url>"
const b64 = did.replace('did:jwk:', '');
const jwk = JSON.parse(
Buffer.from(b64, 'base64url').toString('utf-8')
);
// jwk = { kty: 'EC', crv: 'P-256', x: '...', y: '...' }

This is self-resolution — the public key is embedded in the DID. No network lookup.

Step 3: Verify the ES256 signature

import { importJWK, jwtVerify } from 'jose';

const publicKey = await importJWK(jwk, 'ES256');
const { payload } = await jwtVerify(token, publicKey, {
audience: 'myapp.com', // your clientId
});

Step 4: Verify the Key Binding JWT (full VP)

If you have the raw SD-JWT VP (not the access token), the compact serialisation is split by ~:

<JWT>~<disclosure1>~<disclosure2>~<KB-JWT>
const parts = vpToken.split('~');
const jwt = parts[0];
const disclosures = parts.slice(1, -1);
const kbJwt = parts[parts.length - 1];

// Verify KB-JWT: aud, nonce, iat
const { payload: kbPayload } = await jwtVerify(kbJwt, publicKey, {
audience: 'myapp.com',
});
// kbPayload.nonce should match the nonce you sent in the auth request

Step 5: Verify disclosures

Each disclosure is a base64url-encoded JSON array [salt, claim_name, value]. The JWT payload's _sd array contains SHA-256 hashes of each disclosure:

import { createHash } from 'crypto';

for (const disclosure of disclosures) {
const decoded = JSON.parse(Buffer.from(disclosure, 'base64url').toString());
const [salt, name, value] = decoded;
const hash = createHash('sha256').update(disclosure).digest('base64url');

// Verify hash is in payload._sd
if (!payload._sd.includes(hash)) {
throw new Error(`Disclosure hash mismatch for claim: ${name}`);
}
}

Your API verification endpoint (/api/verify)

The SDK includes verifyPath in every auth request so the extension knows where to send the credential via direct_post. A minimal implementation:

// app/api/verify/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
const body = await req.json();
// body.vp_token — the SD-JWT VP
// body.state — CSRF state (match against sessionStorage value)

// Verify vp_token using the steps above or a higher-level library
// Create your own session, issue a cookie, etc.

return NextResponse.json({ ok: true });
}

Verification is fully offline

The did:jwk method is self-resolving — the public key is embedded in the DID string. Verification does not require:

  • A DNS lookup.
  • A call to any mwen.io server.
  • A call to a DID registry.
  • Any dependency on mwen.io infrastructure being online.

Your application can verify credentials even if mwen.io is unreachable.


Performance

OperationTime
did:jwk self-resolution (decode)< 1 ms
ES256 signature verification (crypto.subtle)< 10 ms
Full verifyAccessTokenFromHeader() call< 15 ms