Introduction
This guide walks you through building a custom login page for the Hanko FlowAPI using the hanko-frontend-sdk
. The
resulting page supports email and passcode login, as well as passwords, passkeys, and passkey autofill. It also includes
session management features, such as redirecting after login and handling logout functionality. The FlowAPI is part of
Hanko, a user authentication platform, and we’ll use a Hanko Cloud project configured for simplicity.
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
Create a Hanko Cloud Project
Go to 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.
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 .
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
Obtain your API URL
In the sidebar, select Dashboard .
Find your project’s API URL (e.g., https://<project-id>.hanko.io
).
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
Update 'package.json'
{
"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"
}
}
Install Dependencies
Run in the project folder:
Update 'index.html' (the login page)
<! 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 >
Update 'success.html' (shown after login)
<! 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 >
Update 'style.css'
body {
font-family : Arial , sans-serif ;
display : flex ;
justify-content : center ;
align-items : center ;
height : 100 vh ;
margin : 0 ;
background-color : #f4f4f4 ;
}
#app {
background : white ;
padding : 20 px ;
border-radius : 8 px ;
box-shadow : 0 0 10 px rgba ( 0 , 0 , 0 , 0.1 );
width : 100 % ;
max-width : 400 px ;
text-align : center ;
}
h1 {
font-size : 24 px ;
margin-bottom : 20 px ;
}
.form-group {
margin-bottom : 15 px ;
text-align : left ;
}
label {
display : block ;
margin-bottom : 5 px ;
}
input {
width : -webkit-fill-available ;
padding : 8 px ;
border : 1 px solid #ccc ;
border-radius : 4 px ;
}
button {
padding : 10 px 20 px ;
background-color : #007bff ;
color : white ;
border : none ;
border-radius : 4 px ;
cursor : pointer ;
margin : 5 px ;
}
button :hover:not ( :disabled ) {
background-color : #0056b3 ;
}
button :disabled {
background-color : #90bdff ;
}
button .loading {
position : relative ;
padding-left : 35 px ;
}
button .loading::before {
content : '' ;
position : absolute ;
left : 10 px ;
top : 50 % ;
transform : translateY ( -50 % );
width : 12 px ;
height : 12 px ;
border : 2 px solid white ;
border-top : 2 px solid transparent ;
border-radius : 50 % ;
animation : spin 0.6 s linear infinite ;
}
div .loading {
position : relative ;
}
div .loading::before {
content : '' ;
position : absolute ;
left : 50 % ;
top : 50 % ;
transform : translateY ( -50 % );
width : 16 px ;
height : 16 px ;
border : 2 px solid #007bff ;
border-top : 2 px solid transparent ;
border-radius : 50 % ;
animation : spin 0.8 s linear infinite ;
}
@keyframes spin {
0% { transform : translateY ( -50 % ) rotate ( 0 deg ); }
100% { transform : translateY ( -50 % ) rotate ( 360 deg ); }
}
#actions {
display : flex ;
justify-content : center ;
flex-wrap : wrap ;
padding : 15 px ;
margin-top : 15 px ;
background-color : #f8f9fa ;
border : 1 px solid #e0e7ff ;
border-radius : 8 px ;
}
.error {
color : red ;
font-size : 14 px ;
margin-top : 5 px ;
}
See all 118 lines
Start the Local Server
Run the following to start the server at http://localhost:3000 :
Open http://localhost:3000
in your browser to verify the page loads (it will be empty until we add JavaScript).
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.
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" );
}
See all 59 lines
Replace https://<your-project-id>.hanko.io
with your project’s API endpoint from the Hanko Cloud dashboard.
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:
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 ();
});
}
See all 276 lines
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
.
Step 4: Run and Test the Login Page
Always use your own email address in the following steps. The Hanko API sends real emails, so proceed with caution!
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:
Run the local server:
Open http://localhost:3000
in your browser.
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.
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"
.
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.
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.
References