> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hanko.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a custom login page

> A step-by-step guide to create a custom login page using the Hanko FlowAPI and Hanko Frontend SDK.

<div class="hidden">
  **Hanko Flow API Custom Login Page Guide**:

  **About Hanko**:

  Hanko is a modern open source authentication solution and the fastest way you integrate passkeys, 2FA, SSO, and more—with full control over your data. Move between self-hosted and Hanko Cloud anytime. No lock-in. Just Auth how it should be: secure, user friendly, and fully yours.

  **What This Guide Covers**: This guide demonstrates how to build a custom login page using the Hanko Flow API, showing you how to create a complete authentication interface that supports email/passcode login, passwords, passkeys, and passkey autofill functionality.

  **Key Technologies**:

  * Hanko Flow API
  * REST endpoints
  * JavaScript/TypeScript
  * hanko-frontend-sdk
  * custom UI development
  * HTML/CSS

  **Prerequisites**:

  * Basic understanding of REST APIs
  * Frontend development with JavaScript/HTML/CSS
  * Node.js development environment
  * Modern web browser for testing
  * Hanko Cloud account

  **Tasks You'll Complete**:

  * Set up a Hanko Cloud project and development environment
  * Create project structure with HTML, CSS, and JavaScript files
  * Initialize the Flow API login flow using the frontend-SDK
  * Implement state handlers for different authentication states
  * Build dynamic UI rendering based on Flow API responses
  * Handle user inputs, form validation, and error states
  * Integrate passkey support and autofill functionality
  * Test the complete authentication flow with session management
  * Implement logout functionality and session handling
</div>

# Introduction

This guide shows you how to build a custom login page using the Hanko Flow API and the `hanko-frontend-sdk`. You'll create a complete authentication interface that supports multiple login methods including email/passcode authentication, passwords, and passkeys with autofill capabilities. The page will include full session management with login redirects and logout functionality.

By the end, you'll have a working login page that:

* Initializes the login flow via the `/login` endpoint.
* Dynamically renders the UI based on the current flow state.
* Handles user inputs, errors, and session events.
* Runs locally at `http://localhost:3000` and authenticates a test user.

## Prerequisites

Before starting, ensure you have:

* **Node.js** installed for running a local server and installing dependencies.
* A modern browser (e.g., Chrome, Firefox) for testing passkey autofill.
* A Hanko Cloud project set up with a test user (see Step 1).
* Basic knowledge of JavaScript, HTML, and CSS.

## Step 1: Set up the development environment

<Steps>
  <Step title="Create a Hanko Cloud project">
    * Go to [cloud.hanko.io](https://cloud.hanko.io) and log in.
    * In the sidebar, select **All Projects**.
    * Click **Create Project** to open the setup wizard.
    * Choose **Hanko** ("Authentication and user management") as the project type and click **Create Project**.
    * Enter a project name (e.g., "Custom Login Page").
    * Set the App URL to `http://localhost:3000` (where the login page will run locally).
    * Click **Create Project** to complete the wizard.
  </Step>

  <Step title="Configure the project">
    To reduce the complexity of this example, we disable certain login features:

    * In the sidebar, go to **Settings > 2FA**.
      * Uncheck the entire **2FA** section.
      * Click **Save**.
    * Under **Device trust** (further down on the same page):
      * Select **Never** as the "Device trust policy" from the dropdown.
      * Click **Save**.
  </Step>

  <Step title="Create a test user">
    * In the sidebar, select **Users**.
    * Click **Create new** to open the user creation wizard.
    * Enter your real email address (in order to receive passcodes).
    * Click **Create new User** to finish.
    * From the list of users, click on the newly created entry.
    * Under "Passwords", click on **Set password**.
    * Enter a password and click **Create password**
  </Step>

  <Step title="Obtain your API URL">
    1. In the sidebar, select **Dashboard**.
    2. Find your project's API URL (e.g., `https://<project-id>.hanko.io`).
  </Step>

  <Step title="Set up the project structure">
    Create a project folder (e.g., `hanko-login-page`) and set up the following files:

    ```
    hanko-login-page/
    ├ index.html
    ├ script.js
    ├ style.css
    ├ success.html
    └ package.json
    ```
  </Step>

  <Step title="Update 'package.json'">
    ```json theme={null}
    {
      "name": "hanko-login-page",
      "version": "1.0.0",
      "scripts": {
        "start": "parcel index.html --port 3000",
        "build": "parcel build index.html success.html"
      },
      "dependencies": {
        "@teamhanko/hanko-frontend-sdk": "^2.0.0",
        "parcel": "^2.14.4"
      }
    }
    ```
  </Step>

  <Step title="Install dependencies">
    Run in the project folder:

    ```bash theme={null}
    npm install
    ```
  </Step>

  <Step title="Update 'index.html' (the login page)">
    ```html theme={null}
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Hanko Login</title>
      <link rel="stylesheet" href="style.css">
    </head>
    <body>
      <div id="app">
        <div class="loading"></div>
      </div>
      <script type="module" src="script.js"></script>
    </body>
    </html>
    ```
  </Step>

  <Step title="Update 'success.html' (shown after login)">
    ```html theme={null}
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Login Success</title>
      <link rel="stylesheet" href="style.css">
    </head>
    <body>
      <div id="app">
        <h1>Login Successful</h1>
        <p>Welcome! You are now logged in.</p>
        <button id="logout">Log Out</button>
      </div>
      <script type="module" src="script.js"></script>
    </body>
    </html>
    ```
  </Step>

  <Step title="Update 'style.css'">
    ```css [expandable] theme={null}
    body {
      font-family: Arial, sans-serif;
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
      margin: 0;
      background-color: #f4f4f4;
    }

    #app {
      background: white;
      padding: 20px;
      border-radius: 8px;
      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
      width: 100%;
      max-width: 400px;
      text-align: center;
    }

    h1 {
      font-size: 24px;
      margin-bottom: 20px;
    }

    .form-group {
      margin-bottom: 15px;
      text-align: left;
    }

    label {
      display: block;
      margin-bottom: 5px;
    }

    input {
      width: -webkit-fill-available;
      padding: 8px;
      border: 1px solid #ccc;
      border-radius: 4px;
    }

    button {
      padding: 10px 20px;
      background-color: #007bff;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      margin: 5px;
    }

    button:hover:not(:disabled) {
      background-color: #0056b3;
    }

    button:disabled {
      background-color: #90bdff;
    }

    button.loading {
      position: relative;
      padding-left: 35px;
    }

    button.loading::before {
      content: '';
      position: absolute;
      left: 10px;
      top: 50%;
      transform: translateY(-50%);
      width: 12px;
      height: 12px;
      border: 2px solid white;
      border-top: 2px solid transparent;
      border-radius: 50%;
      animation: spin 0.6s linear infinite;
    }

    div.loading {
      position: relative;
    }

    div.loading::before {
      content: '';
      position: absolute;
      left: 50%;
      top: 50%;
      transform: translateY(-50%);
      width: 16px;
      height: 16px;
      border: 2px solid #007bff;
      border-top: 2px solid transparent;
      border-radius: 50%;
      animation: spin 0.8s linear infinite;
    }

    @keyframes spin {
      0% { transform: translateY(-50%) rotate(0deg); }
      100% { transform: translateY(-50%) rotate(360deg); }
    }

    #actions {
      display: flex;
      justify-content: center;
      flex-wrap: wrap;
      padding: 15px;
      margin-top: 15px;
      background-color: #f8f9fa;
      border: 1px solid #e0e7ff;
      border-radius: 8px;
    }

    .error {
      color: red;
      font-size: 14px;
      margin-top: 5px;
    }
    ```
  </Step>

  <Step title="Start the local server">
    Run the following to start the server at [http://localhost:3000](http://localhost:3000):

    ```bash theme={null}
    npm start
    ```

    Open `http://localhost:3000` in your browser to verify the page loads (it will be empty until we add JavaScript).
  </Step>
</Steps>

## Step 2: Initialize the login flow

In `script.js`, initialize the Hanko instance and start the login flow. If the user is on `success.html`, set up logout
functionality instead.

```js [expandable] theme={null}
import { Hanko } from "@teamhanko/hanko-frontend-sdk";

// Replace with your Hanko Cloud project's API endpoint
const API_URL = "https://<your-project-id>.hanko.io";

// Initialize Hanko instance
const hanko = new Hanko(API_URL);

// Check if we're on the success page
const isSuccessPage = window.location.pathname.includes("success.html");

if (isSuccessPage) {
  // Handle logout on success page
  const logoutButton = document.getElementById("logout");
  if (logoutButton) {
    logoutButton.addEventListener("click", async () => {
      try {
        await hanko.logout();
      } catch (error) {
        console.error("Logout failed:", error);
        alert("Failed to log out. Please try again.");
      }
    });
  }
  // Redirect to login page on logout
  hanko.onUserLoggedOut(() => {
    window.location.href = "/index.html";
  });
} else {
  // Listen for state changes
  hanko.onBeforeStateChange(({ state }) => {
    const app = document.getElementById("app");

    app.querySelectorAll("button").forEach(button => {
      const actionName = button.dataset.action;

      // Disable all buttons while loading
      button.disabled = true;

      // Display the loading spinner
      if (state.invokedAction.name == actionName) {
        button.classList.add("loading");
      }
    });
  });

  // Listen for state changes
  hanko.onAfterStateChange(({ state }) => {
    handleState(state); // To be implemented in the next step of this guide
  });

  // Listen for successful login
  hanko.onSessionCreated(() => {
    window.location.href = "/success.html";
  });

  // Initialize login flow on index.html
  hanko.createState("login");
}
```

<Tip>
  Replace `https://<your-project-id>.hanko.io` with your project's API endpoint from the Hanko Cloud dashboard.
</Tip>

## Step 3: Handle flow states and render UI

Implement handler functions to process each state and render the appropriate UI. The `hanko.onAfterStateChange` event is
used to update the UI after state transitions. We'll also create a `generateInput` function to dynamically generate input
elements with validation attributes (e.g., minlength, maxlength, required) based on the FlowAPI's input metadata.

Add the following code to the `script.js` file:

```js [expandable] theme={null}
function generateInput(input, options = {}) {
  const { name, type, min_length, max_length, required, error } = input;
  const { autocomplete } = options;

  // Build attributes
  const attributes = [
    `type="${type}"`,
    `id="${name}"`,
    `name="${name}"`,
    required ? "required" : "",
    min_length ? `minlength="${min_length}"` : "",
    max_length ? `maxlength="${max_length}"` : "",
    autocomplete ? `autocomplete="${autocomplete}"` : ""
  ].join(" ");

  // Generate HTML for the input group
  return `
    <div class="form-group">
      <label for="${name}">${name.charAt(0).toUpperCase() + name.slice(1)}</label>
      <input ${attributes}>${error ? `<p class="error">${error.message}</p>` : ""}
    </div>`;
}

function handleState(state) {
  const app = document.getElementById("app");
  app.innerHTML = ""; // Clear previous content

  switch (state.name) {
    case "login_init":
      renderLoginInit(state);
      state.passkeyAutofillActivation(); // Enable passkey autofill
      break;
    case "login_method_chooser":
      renderLoginMethodChooser(state);
      break;
    case "onboarding_create_passkey":
      renderOnboardingCreatePasskey(state);
      break;
    case "passcode_confirmation":
      renderPasscodeConfirmation(state);
      break;
    case "login_password":
      renderLoginPassword(state);
      break;
    case "login_password_recovery":
      renderLoginPasswordRecovery(state);
      break;
    case "error":
      renderError(state);
      break;
    default:
      app.innerHTML = `<div class="loading"></div>`;
  }
}

function renderLoginInit(state) {
  const app = document.getElementById("app");
  const error = state.error;
  const action = state.actions.continue_with_login_identifier;
  const emailInput = action.inputs.email || action.inputs.identifier;
  const inputHtml = generateInput(emailInput, { autocomplete: "username webauthn" });

  app.innerHTML = `
    <h1>Log in</h1>
    ${error ? `<p class="error">${error.message}</p>` : ""}
    <form id="login-form">
      ${inputHtml}
      <button data-action="continue_with_login_identifier" type="submit">Continue</button>
    </form>`;

  const form = document.getElementById("login-form");

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const email = form.querySelector(`#${emailInput.name}`).value;
    await action.run({[emailInput.name]: email });
  });
}

function renderLoginMethodChooser(state) {
  const app = document.getElementById("app");
  const error = state.error;

  app.innerHTML = `
    <h1>Choose login method</h1>
    ${error ? `<p class="error">${error.message}</p>` : ""}
    <div id="actions"></div>`;

  const actionsDiv = document.getElementById("actions");
  const actions = state.actions;

  if (actions.continue_to_password_login.enabled) {
    actionsDiv.innerHTML += `<button data-action="continue_to_password_login">Use password</button>`;
  }

  if (actions.continue_to_passcode_confirmation.enabled) {
    actionsDiv.innerHTML += `<button data-action="continue_to_passcode_confirmation">Use passcode</button>`;
  }

  if (actions.webauthn_generate_request_options.enabled) {
    actionsDiv.innerHTML += `<button data-action="webauthn_generate_request_options">Use passkey</button>`;
  }

  if (actions.back.enabled) {
    actionsDiv.innerHTML += `<button data-action="back">Back</button>`;
  }

  actionsDiv.querySelectorAll("button").forEach((button) => {
    button.addEventListener("click", async (e) => {
      e.preventDefault();
      const actionName = button.dataset.action;
      await actions[actionName].run();
    });
  });
}

function renderOnboardingCreatePasskey(state) {
  const app = document.getElementById("app");
  const error = state.error;

  app.innerHTML = `
    <h1>Create a passkey</h1>
    ${error ? `<p class="error">${error.message}</p>` : ""}
    <p>Add a passkey for faster login (optional).</p>
    <div id="actions"></div>`;

  const actionsDiv = document.getElementById("actions");
  const actions = state.actions;

  if (actions.webauthn_generate_creation_options.enabled) {
    actionsDiv.innerHTML += `<button data-action="webauthn_generate_creation_options">Create passkey</button>`;
  }

  if (actions.skip.enabled) {
    actionsDiv.innerHTML += `<button data-action="skip">Skip</button>`;
  }

  if (actions.back.enabled) {
    actionsDiv.innerHTML += `<button data-action="back">Back</button>`;
  }

  actionsDiv.querySelectorAll("button").forEach(button => {
    button.addEventListener("click", async () => {
      const actionName = button.dataset.action;
      await actions[actionName].run();
    });
  });
}

function renderPasscodeConfirmation(state) {
  const app = document.getElementById("app");
  const error = state.error;
  const verifyAction = state.actions.verify_passcode;
  const codeInput = verifyAction.inputs.code;
  const inputHtml = generateInput(codeInput);

  app.innerHTML = `
    <h1>Enter passcode</h1>
    ${error ? `<p class="error">${error.message}</p>` : ""}
    <p>Check your email for the passcode.</p>
    <form id="passcode-form">
      ${inputHtml}
      <button data-action="verify_passcode" type="submit">Verify</button>
    </form>
    <div id="actions"></div>`;

  const form = document.getElementById("passcode-form");

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const code = form.querySelector(`#${codeInput.name}`).value;
    await verifyAction.run({ code });
  });

  const actionsDiv = document.getElementById("actions");

  if (state.actions.resend_passcode.enabled) {
    actionsDiv.innerHTML += `<button data-action="resend_passcode">Resend passcode</button>`;
  }

  if (state.actions.back.enabled) {
    actionsDiv.innerHTML += `<button data-action="back">Back</button>`;
  }

  actionsDiv.querySelectorAll("button").forEach(button => {
    button.addEventListener("click", async () => {
      const actionName = button.dataset.action;
      await state.actions[actionName].run();
    });
  });
}

function renderLoginPassword(state) {
  const app = document.getElementById("app");
  const error = state.error;
  const loginAction = state.actions.password_login;
  const passwordInput = loginAction.inputs.password;
  const inputHtml = generateInput(passwordInput);

  app.innerHTML = `
    <h1>Enter Password</h1>
    ${error ? `<p class="error">${error.message}</p>` : ""}
    <form id="password-form">
      ${inputHtml}
      <button data-action="password_login" type="submit">Continue</button>
    </form>
    <div id="actions"></div>`;

  const form = document.getElementById("password-form");

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const password = form.querySelector(`#${passwordInput.name}`).value;
    await loginAction.run({ password });
  });

  const actionsDiv = document.getElementById("actions");

  if (state.actions.continue_to_passcode_confirmation_recovery.enabled) {
    actionsDiv.innerHTML += `<button data-action="continue_to_passcode_confirmation_recovery">Forgot your password?</button>`;
  }

  if (state.actions.continue_to_login_method_chooser.enabled) {
    actionsDiv.innerHTML += `<button data-action="continue_to_login_method_chooser">Choose another method</button>`;
  }

  if (state.actions.back.enabled) {
    actionsDiv.innerHTML += `<button data-action="back">Back</button>`;
  }

  actionsDiv.querySelectorAll("button").forEach(button => {
    button.addEventListener("click", async () => {
      const actionName = button.dataset.action;
      await state.actions[actionName].run();
    });
  });
}

function renderLoginPasswordRecovery(state) {
  const app = document.getElementById("app");
  const error = state.error;
  const recoveryAction = state.actions.password_recovery;
  const passwordInput = recoveryAction.inputs.new_password;
  const inputHtml = generateInput(passwordInput);

  app.innerHTML = `
    <h1>Enter a new password</h1>
    ${error ? `<p class="error">${error.message}</p>` : ""}
    <form id="password-recovery-form">
      ${inputHtml}
      <button data-action="password_recovery" type="submit">Save</button>
    </form>
    <div id="actions"></div>`;

  const form = document.getElementById("password-recovery-form");

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const new_password = form.querySelector(`#${passwordInput.name}`).value;
    await recoveryAction.run({ new_password });
  });
}

function renderError(state) {
  const app = document.getElementById("app");
  const error = state.error;

  app.innerHTML = `
    <h1>Error</h1>
    <p class="error">${error.message}</p>
    <button id="retry">Try again</button>`;

  document.getElementById("retry").addEventListener("click", () => {
    initializeLoginFlow();
  });
}
```

<Note>
  * The action execution logic is embedded in each state's form or button handlers.
  * The `hanko.onAfterStateChange` event ensures the UI updates after each action, and `hanko.onSessionCreated` redirects
    to `success.html` on successful login.
  * On `success.html`, the logout button calls `hanko.logout()`, and `hanko.onUserLoggedOut` redirects back to the
    `index.html`.
  * Validation Errors: Each state renderer checks for `state.actions.<action>.inputs.<input>.error` and displays the
    message (e.g., invalid passcode).
  * Technical Errors: The error state handler displays `state.error.message`.
</Note>

## Step 4: Run and test the login page

<Warning>
  Always **use your own email address** in the following steps. The Hanko API sends real emails, so proceed with caution!
</Warning>

<Steps>
  <Step title="Run the application">
    * Ensure the Hanko Cloud project is configured as described (App URL: `http://localhost:3000`, no 2FA, device trust
      "Never").
    * Update `API_URL` in `script.js` with your Hanko Cloud project's API endpoint.
    * Rebuild the App:
      ```bash theme={null}
      npm run build
      ```
    * Run the local server:
      ```bash theme={null}
      npm start
      ```
    * Open `http://localhost:3000` in your browser.
  </Step>

  <Step title="Test the login flow">
    * Enter the test user's email.
    * Check your email for the passcode, enter the code, and click submit.
    * If prompted, register a passkey.
    * On success, you'll be redirected to `success.html`.
    * Click Log Out to return to the login page.
  </Step>

  <Step title="Troubleshooting">
    * CORS Errors: Ensure the App URL in the Hanko Cloud dashboard matches `http://localhost:3000`.
    * Invalid API URL: Verify the `API_URL` in `script.js`.
    * No Passcode Received: Confirm the test user's email is correct and check your spam folder.
    * Passkey Autofill Not Working: Use a passkey-compatible browser (e.g., Chrome) and ensure the input has
      `autocomplete="username webauthn"`.
  </Step>
</Steps>

## Conclusion

You've built a working login page that:

* Uses the FlowAPI and `hanko-frontend-sdk` to handle email/passcode login.
* Supports passwords, passkeys and passkey autofill in the `login_init` state.
* Manages sessions with redirect after login and logout functionality.
* Handles errors gracefully across all specified states.

<Tip>
  Explore next steps like enhancing the UI with advanced CSS or a framework like Bootstrap, adding support for additional
  states or actions (e.g., password login), or integrating the login page into a larger application. Additionally, the
  FlowAPI supports registration and user profile management flows.
</Tip>

## References

<Card title="API reference">
  [https://docs.hanko.io/api-reference/flow/login](https://docs.hanko.io/api-reference/flow/login)
</Card>

<Card title="Frontend SDK">
  [https://github.com/teamhanko/hanko/tree/main/frontend/frontend-sdk](https://github.com/teamhanko/hanko/tree/main/frontend/frontend-sdk)
</Card>
