> For the complete documentation index, see [llms.txt](https://docs.empe.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.empe.io/getting-started/tutorial/dashboard.md).

# Creating a Protected Dashboard

**What We Are Doing:**

* Adding a protected resource (served at `/authorization/dashboard`) that only verified users can access.
* Decoding the app session token to display the claims that were disclosed during verification.

**Why:** To show the end result of verification, we'll display a dashboard page that uses the credential data the holder presented. After a successful verification, our backend issues a short-lived session token (a symmetric JWT we sign ourselves) — the `/verifier/kyc-sessions/:sessionId/token` route from [Creating a Verification Endpoint](/getting-started/tutorial/verify-endpoint.md), which calls the `onKycVerified` helper from [Setting Up the Verification Flow](/getting-started/tutorial/verification-flow.md). The dashboard reads that token and renders whatever the verification flow chose to embed in it — no further calls to the Verifier are needed.

The verification session returns only session metadata (`id`, `verifierId`, `state`, `authorizationRequestId`, `authorizationRequestUri`, `authorizationResponseRedirectUri`, `expiresAt`, `errorMessage`). To show the holder DID and claims, your flow must obtain them — e.g. via `POST /credentials/verify`, which returns the credential `payload` (holder binding in `payload.cnf`) and `prettyClaims` — and sign them into the token as `sub` and `claims`, as `onKycVerified` does when you pass them. Otherwise those dashboard fields stay empty.

**Steps:** Create `public/dashboard.html`:

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>Dashboard</title>
</head>
<body>
<h2>Welcome to the Dashboard!</h2>
<p>DID: <span id="did"></span></p>
<p>First Name: <span id="first-name"></span></p>
<p>Last Name: <span id="last-name"></span></p>
<p>Age: <span id="age"></span></p>

<script>
    (async () => {
        const token = localStorage.getItem("access_token");
        if (!token) {
            window.location.href = "/";
            return;
        }

        const res = await fetch('/authorization/decode-access-token', {
            headers: { Authorization: `Bearer ${token}` }
        });
        if (!res.ok) {
            // Token missing, expired, or invalid — send the user back to verify again.
            localStorage.removeItem("access_token");
            window.location.href = "/";
            return;
        }

        // The DID/claim fields are filled only if the verification flow
        // signed them into the token (see the hint above).
        const data = await res.json();
        const claims = data.claims ?? {};
        document.getElementById("did").innerText = data.did ?? "—";
        document.getElementById("first-name").innerText = claims.first_name ?? "—";
        document.getElementById("last-name").innerText = claims.last_name ?? "—";
        document.getElementById("age").innerText = claims.age ?? "—";
    })();
</script>
</body>
</html>
```

Add a route to decode the session token and extract the claims in `src/authorization-routes.js`. It verifies the token with the `JWT_SECRET` you set in your `.env` during [Project Setup](/getting-started/tutorial/project-setup.md):

```javascript
import { Router } from "express";
import jwt from "jsonwebtoken";
import dotenv from "dotenv";

dotenv.config();

const router = Router();
const JWT_SECRET = process.env.JWT_SECRET; // Secret used to sign/verify our own session token

router.get("/decode-access-token", (req, res) => {
    const authHeader = req.headers["authorization"];
    const access_token = authHeader?.split(" ")[1];
    if (!access_token) return res.status(401).json({ error: "Unauthorized" });

    try {
        // Verify our own session token. Because we signed it with `exp`,
        // jwt.verify() also rejects expired tokens automatically.
        const decoded = jwt.verify(access_token, JWT_SECRET);

        // We read the holder DID and disclosed claims straight from the payload.
        // These fields are present only if the verification flow signed them in
        // (see the hint at the top of this page).
        res.json({
            did: decoded.sub,        // the holder's did:key / did:web (if signed in)
            claims: decoded.claims,  // disclosed claims, e.g. { first_name, last_name, age } (if signed in)
        });
    } catch (error) {
        // Invalid signature or expired token.
        return res.status(401).json({ error: "Invalid or expired token" });
    }
});

router.get("/dashboard", (req, res) => {
    res.sendFile("dashboard.html", { root: "public" });
});

export default router;
```

Finally, mount this router in `src/index.js` under the `/authorization` prefix, next to the issuer and verifier routers:

```javascript
// src/index.js — add the import at the top:
import authorizationRouter from "./authorization-routes.js";

// ...and mount it with the other routers:
app.use("/authorization", express.json(), authorizationRouter);
```

Now, after verification, the user is redirected to `/authorization/dashboard`, and the dashboard displays what the verification flow signed into the session token.

A few things to keep in mind:

* **Match the claim keys to your schema.** This example reads `first_name`, `last_name`, and `age` — the claims of the `KYCCredential@1.0:sd-jwt` credential that the "kyc" flow in [Setting Up the Verification Flow](/getting-started/tutorial/verification-flow.md) requires (`age ≥ 18`). Use the exact property names from the credential schema you are verifying; if you verify a different credential, render its own claim keys instead.
* **The DID shown is the holder's DID** — the `did:key` or `did:web` of the wallet that presented the credential, read from the session token's `sub` claim.
* **The disclosed claims come from the verified presentation.** Pass through only the claims the holder chose to disclose (SD-JWT selective disclosure), so the dashboard never sees more than what was presented.

> **Best practice:** Give the app session token a short lifetime. The `onKycVerified` helper signs it with `expiresIn: "1h"` — tighten that (e.g. `"15m"`) for sensitive resources. `jwt.verify()` then rejects expired tokens for you, so a leaked token can't grant access to the protected dashboard indefinitely. This session token is internal to your app and is unrelated to the OIDC access token you use to call the Issuer and Verifier APIs.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.empe.io/getting-started/tutorial/dashboard.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
