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>