AID-I-008.005: Magic-Link & Authentication-Bearing Navigation Isolation

Version 1.20261015 · Actionable sub-technique

Isolate magic links, OAuth authorization redirects and callbacks, password-reset, invitation, email-verification, and other authentication-bearing navigation as separate, brokered transitions. A trusted controller—not the model—must resolve each opaque link handle; verify issuer, audience or client, exact redirect, transaction binding, expiry, state and nonce, PKCE where applicable, and one-time replay state; restrict the dedicated context to signed allowlisted origins with no inherited storage, permissions, downloads, or clipboard; return only a scoped session reference; then destroy the context and verify the destruction receipt. Raw URLs, authorization codes, access or refresh tokens, and link secrets must never enter model context, ordinary logs, or evidence.

Pillars: app, data

Lifecycle: building, operation

Scope boundary

Owns brokered isolation of magic links, OAuth redirects and callbacks, password resets, invitations, email verification, and other authentication-bearing navigation in a dedicated context with issuer, audience, redirect, transaction, expiry, state, nonce, PKCE, replay, origin, and destruction checks. Raw links, codes, and tokens must not enter model context.

AID-I-008.003: AID-I-008.005 brokers authentication-bearing navigation and returns only a scoped session reference; AID-I-008.003 quarantines browser downloads and clipboard transfers. Treating a magic link as an ordinary download or clipboard item does not protect transaction binding, one-time replay state, redirect trust, or token secrecy.

AID-H-019: AID-I-008.005 owns the dedicated authentication-navigation state machine; AID-H-019 owns general URL admission and safe fetch outside authentication transitions. A URL that passes generic admission still requires brokered authentication checks when it carries or creates authority.

Implementation guidance

AID-I-008.005-G001

Treat magic-link and auth-bearing link flows as approval-required authentication transitions in a dedicated browser context.
Authoritative transaction model

Do not decide that a link is safe by searching its path or query for words such as token or callback; encodings and redirects make that heuristic incomplete. Pattern matching may nominate a link for quarantine, but a root-owned authentication broker is authoritative. The broker resolves an opaque link handle, validates the expected issuer and one-time transaction, holds OAuth PKCE verifier or magic-link verification keys, and returns signed receipts. Raw token-bearing URLs never enter model context, prompts, ordinary logs, or evidence.

Dedicated-context authentication controller

The browser dependency below is a privileged lifecycle adapter that returns the real platform context handle plus a creation receipt and later enumerates and destroys the complete page/popup population. A caller-supplied label is never accepted as context identity.

// File: browser/auth_transition_controller.js
import { createHash } from 'node:crypto';

function digest(text) {
  return 'sha256:' + createHash('sha256').update(text).digest('hex');
}

function canonicalOrigin(raw) {
  const url = new URL(raw);
  if (url.protocol !== 'https:' || url.username || url.password) {
    throw new Error('authentication navigation must use credential-free HTTPS');
  }
  return url.origin;
}

export class AuthTransitionController {
  constructor({browser, taskId, signedPolicy, broker, evidence}) {
    if (
      !taskId
      || signedPolicy.schema_version !== 'aidefend.auth-transition-policy.v1'
      || !signedPolicy.policy_sha256
    ) throw new Error('verified auth-transition policy required');
    Object.assign(this, {
      browser, taskId, policy: signedPolicy, broker, evidence
    });
  }

  async complete({opaqueLinkHandle, expectedTransactionId}) {
    // The injected browser object is a privileged lifecycle adapter, not a raw
    // Playwright Browser. It allocates the context and attests its platform ID.
    const allocation = await this.browser.allocateIsolatedContextAndVerify({
      task_id: this.taskId,
      transaction_id: expectedTransactionId,
      options: {
        storageState: undefined,
        serviceWorkers: 'block',
        permissions: [],
        acceptDownloads: false
      }
    });
    if (
      !allocation
      || allocation.schema_version !== 'aidefend.browser-context-allocation.v1'
      || !allocation.context
      || !allocation.context_id
      || !/^sha256:[0-9a-f]{64}$/.test(
        allocation.context_creation_receipt_sha256
      )
    ) throw new Error('browser control plane did not attest the actual context');
    const context = allocation.context;
    const contextId = allocation.context_id;
    let prepared = null;
    let completion = null;
    let destruction = null;
    let brokerClosure = null;
    let navigationUrl = '';
    try {
      prepared = await this.broker.prepareAndVerify({
        task_id: this.taskId,
        opaque_link_handle: opaqueLinkHandle,
        expected_transaction_id: expectedTransactionId,
        dedicated_context_id: contextId,
        context_creation_receipt_sha256:
          allocation.context_creation_receipt_sha256,
        policy_sha256: this.policy.policy_sha256
      });
      if (
        prepared.decision !== 'ALLOW'
        || prepared.transaction_id !== expectedTransactionId
        || prepared.dedicated_context_id !== contextId
        || prepared.context_creation_receipt_sha256
          !== allocation.context_creation_receipt_sha256
        || !['oauth_authorization_code_pkce', 'signed_magic_link'].includes(
          prepared.flow_type
        )
      ) throw new Error('authentication broker denied or misbound transition');
      navigationUrl = prepared.navigation_url;
      const navigationSha256 = digest(navigationUrl);
      if (navigationSha256 !== prepared.navigation_url_sha256) {
        throw new Error('broker navigation URL digest mismatch');
      }
      const allowedOrigins = new Set(
        prepared.allowed_navigation_origins.map(canonicalOrigin)
      );
      if (!allowedOrigins.has(canonicalOrigin(navigationUrl))) {
        throw new Error('initial issuer origin not in signed transition');
      }
      await context.route('**/*', async (route) => {
        const request = route.request();
        // Enforce the signed origin boundary on navigation and every subresource.
        // A trusted top-level page may still attempt script, image, iframe, or fetch egress.
        let targetOrigin;
        try {
          targetOrigin = canonicalOrigin(request.url());
        } catch {
          await route.abort('blockedbyclient');
          return;
        }
        if (!allowedOrigins.has(targetOrigin)) {
          await route.abort('blockedbyclient');
          return;
        }
        await route.fallback();
      });
      const page = await context.newPage();
      await page.goto(navigationUrl, {waitUntil: 'domcontentloaded'});
      navigationUrl = '';
      await page.waitForURL(
        (url) => digest(url.origin + url.pathname)
          === prepared.redirect_endpoint_sha256,
        {timeout: this.policy.transition_timeout_ms}
      );
      let callbackUrl = page.url();
      completion = await this.broker.completeAndVerify({
        task_id: this.taskId,
        transaction_id: expectedTransactionId,
        prepare_receipt_jws: prepared.receipt_jws,
        callback_url: callbackUrl,
        callback_url_sha256: digest(callbackUrl),
        dedicated_context_id: contextId,
        policy_sha256: this.policy.policy_sha256
      });
      callbackUrl = '';
      if (
        completion.decision !== 'COMPLETED'
        || completion.transaction_id !== expectedTransactionId
        || completion.prepare_receipt_sha256 !== prepared.receipt_sha256
        || !completion.one_time_consumed
      ) throw new Error('authentication completion receipt invalid');
    } finally {
      // The browser adapter owns a durable, idempotent terminal record. It
      // enumerates every page/popup in this context and confirms destruction.
      destruction = await allocation.closeAndVerify({
        reason: 'authentication transition completed',
        completion_receipt_sha256: completion
          ? completion.receipt_sha256
          : null
      });
      if (
        destruction.state !== 'DESTROYED'
        || destruction.dedicated_context_id !== contextId
        || destruction.context_creation_receipt_sha256
          !== allocation.context_creation_receipt_sha256
        || destruction.page_population_complete !== true
        || !/^sha256:[0-9a-f]{64}$/.test(
          destruction.page_population_sha256
        )
        || !Number.isSafeInteger(destruction.page_population_count)
        || destruction.page_population_count < 1
      ) throw new Error('actual browser context destruction is unresolved');
      brokerClosure =
        await this.broker.confirmContextDestroyedAndVerify({
          task_id: this.taskId,
          transaction_id: expectedTransactionId,
          dedicated_context_id: contextId,
          context_creation_receipt_sha256:
            allocation.context_creation_receipt_sha256,
          browser_destruction_receipt_sha256:
            destruction.receipt_sha256,
          completion_receipt_sha256: completion
            ? completion.receipt_sha256
            : null
        });
      if (
        brokerClosure.state !== 'DESTROYED'
        || brokerClosure.dedicated_context_id !== contextId
        || brokerClosure.browser_destruction_receipt_sha256
          !== destruction.receipt_sha256
      ) throw new Error('broker and browser terminal receipts differ');
    }
    const event = await this.evidence.appendAndVerify({
      schema_version: 'aidefend.auth-transition-event.v1',
      control: 'AID-I-008.005',
      task_id: this.taskId,
      transaction_id: expectedTransactionId,
      flow_type: prepared.flow_type,
      issuer_origin: prepared.issuer_origin,
      navigation_url_sha256: prepared.navigation_url_sha256,
      redirect_endpoint_sha256: prepared.redirect_endpoint_sha256,
      prepare_receipt_sha256: prepared.receipt_sha256,
      completion_receipt_sha256: completion.receipt_sha256,
      resulting_session_reference: completion.session_reference,
      dedicated_context_id: contextId,
      context_creation_receipt_sha256:
        allocation.context_creation_receipt_sha256,
      browser_destruction_receipt_sha256: destruction.receipt_sha256,
      broker_destruction_receipt_sha256:
        brokerClosure.receipt_sha256,
      page_population_sha256: destruction.page_population_sha256,
      page_population_count: destruction.page_population_count,
      policy_sha256: this.policy.policy_sha256,
      outcome: 'PASS'
    });
    return {
      session_reference: completion.session_reference,
      evidence_receipt_sha256: event.receipt_sha256
    };
  }
}

Broker contract: for OAuth, the broker verifies the registered issuer, authorization endpoint, client and audience, exact redirect URI, state, nonce where used, PKCE S256 challenge, transaction expiry, and one-time state before returning the URL; it exchanges the authorization code server-side with the stored verifier. For a signed magic link, it verifies issuer key, audience, subject binding, expiry, intended action, redirect allowlist, and one-time token ID. The prepare, complete, browser-lifecycle, and broker-destruction receipts bind the task, transaction, link digest, flow, issuer, policy, actual platform context, complete page/popup population, and one-time ledger. Terminal state is durably retrievable by transaction ID after restart or evidence-delivery failure. The controller receives only a scoped session reference, never access or refresh tokens.

Verify safely

replay broker signatures and transaction state and test lookalike domain, redirect, encoding, token leakage, state or PKCE mismatch, replay, cancellation, and reuse.

Open-source tools

Commercial / hosted tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-I-008.005: Magic-Link & Authentication-Bearing Navigation Isolation | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version