# Add hosted Account Security to your dashboard

:::note[Availability]
Hosted Account Security is in a limited production rollout. Enable it only when it appears in the selected application’s Quickstart. The download is a reference adapter, not a published production SDK.
:::

Updated September 15, 2026.

Users open **Security & sign-in** from your dashboard to manage passkeys, authenticator apps, security keys, recovery codes and application sessions. Oathvera uses your saved workspace branding and sign-in domain.

## Before you begin

You need an existing, working Oathvera sign-in integration, a compatible identity environment, a **Server web application** client for seamless entry, and access to your backend session store. Copy the selected environment's issuer and client settings from the application quickstart. An Oathvera administrator account is separate from an application end-user account.

The issuer includes `/t/<tenant-id>` and must match the issuer associated with the user's validated sign-in. Preserve that association if you support more than one issuer or authentication hostname; choose from trusted server configuration, never from a browser-supplied URL. Changing a hostname does not migrate existing cookies or passkeys.

Download the [server adapter](https://developer.oathvera.com/downloads/oathvera-account-center.mjs) and [this guide](https://developer.oathvera.com/downloads/account-security.md). The adapter uses Node-compatible APIs (`node:crypto`, `Buffer`, Fetch and Web Streams); check your server runtime before importing it. It is a reference adapter that connects to your existing authentication, CSRF and storage code.

This release does not provide authenticated password change, verified sign-in-email change or global sign-out. Password recovery remains a separate forgot/reset-password journey.

## 1. Choose your integration

A direct link opens `ISSUER/auth/account?client_id=CLIENT_ID`. It reuses a matching hosted browser session when available; otherwise it asks for authentication. The issuer includes `/t/<tenant-id>`.

For seamless entry tied to the exact user currently signed into your dashboard, use a **Server web application** (`WEB_CONFIDENTIAL`) client and the backend handoff below. This also works when the hosted cookie is missing. Browser-only, native and machine-to-machine clients cannot use this confidential backend exchange. Do not ship the client secret to a browser.

## 2. Register destinations

In Oathvera, open **Applications → your application → Quickstart → Account security center → Configure seamless entry**. Register:

- **Account-center handoff callback:** a dedicated backend GET route, for example `https://your-app.example/account/security/callback`.
- **Return destination:** your dashboard account settings, for example `https://your-app.example/settings/security`.

Both destinations must use the first registered OAuth callback's HTTPS origin. They are separate from your OAuth callback; saving them does not replace existing login URLs. Keep the handoff callback free of query parameters and fragments. Configuring these destinations does not implement the routes inside your dashboard.

Backend configuration is also available through `POST ISSUER/auth/account/configuration?client_id=CLIENT_ID`, using client-secret Basic authentication and JSON containing `callback_uri`, `return_uri` and the current `version` (zero for initial registration). A stale version fails. Save the returned version for subsequent changes.

## 3. Connect the dashboard

Download the current adapter from this documentation site, then configure seamless entry in your application quickstart. Use the [server-only adapter](https://developer.oathvera.com/downloads/oathvera-account-center.mjs) with your existing server session and CSRF checks. It provides `start(request)` for your dashboard's POST button route and `callback(request)` for the registered handoff GET route.

Provide:

- The issuer, client ID and server-held client secret from the selected environment.
- `getSession(request)`: validates your authenticated dashboard cookie and returns `{id, userId, accessToken}` from server storage. Do not populate these fields from browser JSON or query parameters. Refresh expired Oathvera access tokens through your existing backend refresh flow; revoked sessions must fail.
- `verifyCsrf(request)`: validates the initiating dashboard POST using your existing CSRF mechanism.
- `pending.put(state, record)` and `pending.take(state, sessionId)`: durable storage with a 120-second TTL. `take` must atomically consume only a record bound to that dashboard session. Do not use an in-process Map for a multi-instance production backend.

Use a normal POST form or equivalent same-origin, CSRF-protected action labeled **Security & sign-in**. Return the adapter's redirect response to the browser. Oathvera handles hosted-cookie verification or a short callback round trip without asking for credentials when the source session is valid.

Treat launch errors as a visible, retryable failure or require sign-in when the source session is invalid. Never fall back to an unverified account identifier. Configure your callback routes and request logger to redact state and handoff handles. Tokens and client secrets must never be logged.

### Example route wiring

The following example uses the standard Fetch `Request`/`Response` interface. The session and storage imports represent functions you implement in your own backend. Adapt the route dispatch to your framework and return each response, including its status and headers, unchanged.

```js
import { accountCenter } from './oathvera-account-center.mjs';
import { requireDashboardSession, verifyDashboardCsrf } from './auth.js';
import { accountSecurityPending } from './storage.js';

export function createSecurityRoutes(config) {
  // config is trusted, server-only configuration for one issuer/client pair.
  const center = accountCenter({
    issuer: config.issuer,
    clientId: config.clientId,
    clientSecret: config.clientSecret,
    callbackUri: config.callbackUri,
    getSession: async (request) => {
      const session = await requireDashboardSession(request);
      // Validate live session authority and refresh, if needed, in this helper.
      if (session.issuer !== config.issuer ||
          session.clientId !== config.clientId) {
        throw new Error('Dashboard session does not match this environment');
      }
      return {
        id: session.id,
        userId: session.userId,
        accessToken: session.accessToken,
      };
    },
    verifyCsrf: verifyDashboardCsrf,
    pending: accountSecurityPending,
  });

  return {
    // POST /account/security/start
    start: (request) => center.start(request),
    // GET /account/security/callback (register this exact HTTPS URL)
    callback: (request) => center.callback(request),
  };
}
```

| Your application route | Responsibility |
| --- | --- |
| `POST /account/security/start` | Validate the signed-in user and CSRF proof, then return `start(request)`. |
| `GET /account/security/callback` | Preserve the three handoff query fields and current dashboard cookie, then return `callback(request)`. |
| `GET /settings/security` | Existing dashboard settings page used as the registered return destination. |

Render a normal same-origin form in your authenticated dashboard. Substitute the framework-escaped CSRF token generated for this session:

```html
<form method="post" action="/account/security/start">
  <input type="hidden" name="csrf_token" value="YOUR_ESCAPED_CSRF_TOKEN">
  <button type="submit">Security &amp; sign-in</button>
</form>
```

Your `verifyCsrf` function must validate that same field and your existing origin policy. If it reads the body while other middleware also needs it, coordinate body consumption or read a request clone. A normal form follows the redirect as top-level browser navigation; an AJAX request alone will not navigate the page.

### Browser security headers

If your dashboard sets Content Security Policy, its `form-action` must permit the exact trusted authentication origin as well as `'self'`, so the POST can redirect to the hosted page. For example, adapt this directive within your existing policy:

```http
Content-Security-Policy: form-action 'self' https://your-auth-host.example
```

Use the origin of the issuer from trusted server configuration, without the `/t/...` path. Include a previous origin only while your application deliberately supports existing sessions on it. Preserve your other policy directives; do not use a wildcard or a general `https:` allowance. Check actual browser navigation in Safari and Chromium, not only the backend launch response.

Run the callback through your session validation, but exclude it from middleware that silently starts a new login or strips its query parameters. An expired or changed dashboard session should show a safe restart message. After signing in again, start a new handoff from the button. Serve launch/callback errors with `Cache-Control: no-store` and `Referrer-Policy: no-referrer`; avoid third-party assets on callback/error pages. The adapter already sets these headers on successful redirects.

### Session and pending storage

Keep the real Oathvera user access token in protected server storage, associated with the dashboard session, immutable user ID, issuer and client. ID-token claims, email addresses, token hashes and a client-credentials token cannot substitute for this user token. If your login implementation discards it, add server-side token custody and your existing refresh/session validation before enabling the button. Coordinate rotating refresh tokens so concurrent requests cannot replay a superseded token.

The adapter writes the following record under a random state key:

```js
{ sessionId, userId, intent, expiresAt } // expiresAt: Unix milliseconds
```

Implement `put` with expiry after 120 seconds. Implement `take(state, sessionId)` as one atomic database operation or transaction that checks the session binding and expiry, consumes the matching record once, and returns it. A mismatch returns no record and must not consume another session's launch. The adapter additionally checks the user ID, intent and expiry before approval. Use a separate state key per launch, not one pending slot per user, and clean up expired records even when the callback never occurs. Avoid a separate read-then-delete sequence or an eventually consistent store for redemption.

A matching hosted cookie can complete entry without invoking your callback. In that successful path, your pending record expires normally after 120 seconds. The Return link also does not call the handoff callback. Do not treat an unconsumed local record as proof of failure or manually replay it.

The dashboard session cookie must accompany the cross-site top-level GET callback. Use a tested secure cookie policy, commonly `Secure; HttpOnly; SameSite=Lax`, with a path that includes the callback. Verify the behavior in your supported browsers. Keep the callback on the registered console origin and do not relay the browser handoff through an embedded iframe.

## 4. Protocol contract

All backend requests use JSON, registered client-secret Basic authentication, `redirect: manual`, and the selected issuer. Explicitly reject every 300–399 response before reading the body; never follow its Location or forward credentials. This avoids the `redirect: error` incompatibility observed in Workers. Requests must not include an Origin header. Access tokens below are sent only in server-to-server request bodies.

| Endpoint suffix | Request fields | Result |
| --- | --- | --- |
| `/auth/account/launch?client_id=CLIENT_ID` | `user_token`, `state` | `launch_url`, `intent`, `expires_at` |
| `/auth/account/approve?client_id=CLIENT_ID` | `user_token`, `challenge`, `state` | `completion_url`, `expires_at` |

Generate state with at least 32 cryptographically random bytes, encoded as base64url (43–128 characters). Store its association with the current dashboard session and returned intent. Launches expire after 120 seconds; completion handles after at most 30 seconds. Each launch/completion is single-use.

The hosted callback adds `challenge`, `state` and `intent` to the registered callback. Validate all three against your pending launch and the same dashboard session before requesting approval. Oathvera independently requires the same live user, source session and grant. A copied completion URL cannot unlock another browser because redemption also requires the original authentication-host cookie.

## 5. Verification and return behavior

Ordinary access and sensitive changes have separate requirements. Viewing settings reuses valid source-session authority without requiring fresh authentication. The portal lasts at most 30 minutes and cannot outlive its source authority. Adding credentials checks recent primary verification and applicable MFA; removing factors or generating recovery codes retains stronger verification requirements. Existing valid evidence is reused for ten minutes. Refreshing a token does not refresh that evidence.

Federated and social sign-in sessions support ordinary access. Provider redirects without an approved authentication-time mapping do not grant fresh sensitive-action permission. This release uses an enabled local password, email challenge or existing passkey, with required existing MFA, for that verification; provider-only accounts without such a method cannot complete sensitive changes through this page yet.

Cancellation leaves ordinary access available. Explicit locking hides account details and requires fresh verification to unlock. Revoking the source application session also ends its account-center access. Credential changes that revoke existing sessions may require a new sign-in afterwards.

After a backend handoff, Return takes users to the registered return destination. A direct-link entry returns to the registered OAuth callback’s origin. It does not issue an OAuth authorization code or create a replacement dashboard login. Your application must continue validating its own session and reflecting revocation.

## 6. Test before enabling

Keep the button behind your application’s feature/cohort control until these checks pass in the selected environment:

| Scenario | Expected result |
| --- | --- |
| Dashboard signed in, matching hosted cookie | Opens the same account without entering credentials again. |
| Dashboard signed in, hosted cookie missing | Short callback round trip; same account opens without entering credentials again. |
| Dashboard account A, hosted account B | Opens A through the validated handoff; never displays B's settings. |
| Dashboard logout or account switch before callback | Handoff fails; restart from the current authenticated dashboard session. |
| Expired, copied or replayed callback/completion; concurrent redemption | No account access from invalid proof; at most one valid redemption. |
| Access-token refresh or source revocation | Valid refresh can continue; revoked source authority cannot. |
| Sensitive action with recent or stale verification | Reuses qualifying recent evidence; stale evidence prompts inline. |
| Verification cancellation or explicit lock | Cancel preserves ordinary access; lock hides details and requires fresh unlock. |
| Return, ordinary login and logout | Returns to registered settings; existing OIDC routes and session behavior remain valid. |
| Real passkey enrollment and repeat use | Owner completes enrollment, repeat sign-in, cancellation and recovery on supported Safari, Chrome and mobile devices. |

Use a consenting test account owner for real credential changes. Record your application commit, exact registered routes, browser/OS, results and deployment status. Automated protocol coverage does not prove physical passkey ceremonies.

## 7. Troubleshooting

| Symptom | Check and recovery |
| --- | --- |
| Account security center is absent in Quickstart | The selected environment is not enabled or compatible. Confirm availability before integrating; a download alone does not enable it. |
| Launch returns `NOT_AVAILABLE` | Confirm compatibility and saved handoff/return configuration for this exact client and environment. |
| Launch returns `REAUTHENTICATION_REQUIRED` | Revalidate the dashboard session and server-held user access token. Refresh only through your existing permitted flow; otherwise ask the user to sign in and start again. |
| Backend client authentication fails | Confirm client ID, current secret, issuer and Basic authentication. Send from the server with no Origin header. |
| Callback is expired or mismatched | Check the exact registered path, session-cookie delivery, original dashboard user, atomic pending store and 120-second expiry. Start a new launch; do not retry a consumed callback. |
| Saving destinations fails | Use the first OAuth callback's HTTPS origin and a dedicated callback without query or fragment. Reload configuration after a version conflict rather than overwriting another update. |
| Clicking the button makes a request but stays on the page | Use top-level form navigation, preserve the adapter's 303 response, and allow the exact issuer origin in the dashboard's CSP `form-action`. |
| Hosted page opens, but no callback arrives | A matching hosted cookie uses direct entry. Let the pending record expire; do not replay it. |
| User is asked to log in despite being signed in to your app | Confirm the button uses backend launch with the current user's token, rather than only the direct link. Check issuer and session validity. |
| Provider-only user cannot make a sensitive change | Ordinary access is supported; an enabled qualifying local method and applicable MFA are required for fresh verification in this release. |
| Passkey is unavailable after switching authentication hosts | Credentials remain scoped to their enrollment hostname. Preserve existing issuer/hostname continuity. |

For support, capture the time and timezone, operation, environment, HTTP status, safe error code, browser/OS and an application-generated correlation ID. Redact cookies, tokens, secrets, state, intent, challenge and completion handles. Do not attach full callback URLs.
