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
- Extracts the Bearer token from the
Authorizationheader. - Decodes the JWT header and extracts the
kidclaim (adid:jwkURI). - Self-resolves the
did:jwkto obtain the P-256 public key — no network request. - Verifies the ES256 signature using
crypto.subtle.verify(). - Checks
exp(not expired) andaud(matches yourclientId).
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
| Operation | Time |
|---|---|
did:jwk self-resolution (decode) | < 1 ms |
ES256 signature verification (crypto.subtle) | < 10 ms |
Full verifyAccessTokenFromHeader() call | < 15 ms |