Docly Developers
DocsUpdated 18 Oct 2026

Create custom JWT login

How to implement a custom JWT login for your web application: a login page, an authentication endpoint, and a user store with salted password hashes.

Before you build this yourself: Docly has a built-in login for published sites ("Require login" on the publication) that handles passwords, 2FA and lockouts for you. See Ship secure user access fast: let Docly handle login, you handle the permissions. Use a custom login only when your users cannot have Docly accounts.

Create a login end point: #/API/Login.js

The endpoint accepts POST only, looks the user up under <code>#/Users/</code>, compares a salted PBKDF2 hash and writes a JWT cookie on success. Every failure gives the same message and flags the request, so repeated guessing gets the IP blocked by the platform.

import { hashPassword } from "#/Lib/Password.js";

export default (login, password) => {
    // Logging in sets a cookie, so it must never run on a GET (a link on another site could trigger it).
    if (request.method != "POST") throw new Error("Verb not supported");

    // One message for every failure, so the endpoint does not reveal which accounts exist.
    const fail = () => {
        docly.flagActivity("custom login failed");
        throw new Error("Wrong username or password");
    };

    if (!login || !password) fail();
    login = String(login).trim().toLowerCase();
    if (!docly.isEmail(login)) fail();

    // Users live under #/ — nothing under # is served, so the hashes can never be downloaded.
    let user = docly.getJson("#/Users/" + login);
    if (user == null || !user.Salt || !user.PasswordHash) fail();

    if (hashPassword(password, user.Salt) !== user.PasswordHash) fail();

    let jwt = {
        email: login,
        member: user.Member,
        name: user.Name
    };
    docly.writeJwt(jwt);
    return jwt;
}

The hash function lives in its own module so the login and the code that creates users always hash the same way:

// #/Lib/Password.js — shared by Login.js and the code that creates users.
// PBKDF2-HMAC-SHA256 with a per-user salt. Never store or compare plain or MD5-hashed passwords.
export function hashPassword(password, salt) {
    let hex = null;
    crypto.subtle.importKey("raw", password, "PBKDF2", false, ["deriveBits"])
        .then(key => crypto.subtle.deriveBits(
            { name: "PBKDF2", hash: "SHA-256", salt: salt, iterations: 600000 }, key, 256))
        .then(bits => { hex = bits.map(b => b.toString(16).padStart(2, "0")).join(""); });
    return hex;
}

Creating a user stores a random salt next to the hash. Put this behind an endpoint only an administrator can call:

import { hashPassword } from "#/Lib/Password.js";

// Example: store a user. Call this from an admin-only endpoint (POST, access checked first).
function saveUser(email, name, password) {
    let salt = crypto.randomUUID();
    docly.saveJson("#/Users/" + email.trim().toLowerCase(), {
        Name: name,
        Salt: salt,
        PasswordHash: hashPassword(password, salt)
    });
}

Get current user: #/API/GetUser.js

This segment outlines the creation of the /API/GetUser.js endpoint, designed to retrieve the current user's information. It includes code for validating the presence of a JWT in the request and, if authenticated, returns the user details encoded in the JWT, ensuring secure and efficient access management.

export default () => {
    if (request.Jwt === null)
        return docly.denyAccess();
    
    return request.Jwt;    
}

Create the following file: Login.hash

Login.hash is the login page. The form posts the credentials as JSON in the request body to /API/Login and shows the error message as text.

<!DOCTYPE html>
<html>
<head>
    <title>Login</title>
</head>
<body>
    <section id="content">
        <h6>Login</h6>
        <p>Please log in with your email and password.</p>
        <form id="LoginForm">
            <label for="Login">Email</label>
            <input type="email" id="Login" autocomplete="username" required>
            <label for="Password">Password</label>
            <input type="password" id="Password" autocomplete="current-password" required>
            <button type="submit">Login</button>
            <p id="LoginError" hidden></p>
        </form>
    </section>

    <script>
        document.getElementById("LoginForm").addEventListener("submit", async event => {
            event.preventDefault();
            const error = document.getElementById("LoginError");
            error.hidden = true;

            // The password goes in the request BODY — never in the URL, where it ends up in logs.
            const response = await fetch("~/API/Login", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({
                    login: document.getElementById("Login").value,
                    password: document.getElementById("Password").value
                })
            });

            if (response.ok) {
                window.location.href = "~/";
                return;
            }
            const data = await response.json().catch(() => ({}));
            error.textContent = data.message || "Login failed";
            error.hidden = false;
        });
    </script>
</body>
</html>