Skip to content

Open source / Guard/Tripwire / passglyph

passglyph

Egyptian hieroglyphs instead of password dots: matching passwords show the same glyphs, typos show where they happen, nothing is revealed. Per-instance non-extractable HMAC key over each prefix (no per-character mapping), 4-6 bits/glyph, canvas sprite, input stays type=password, dot fallback. React, Vue, <pass-glyph>. Zero deps.

On npm
$ npm install passglyph

README

passglyph

npm CI Node license: MIT size zero deps types

Hieroglyphs instead of password dots. The same password shows the same glyphs in both fields, so you can see a typo where it happens, without the password ever being shown.

Live demo → · Docs (wiki) · Security design · Launch post

<p align="center"><img src="docs/hero.svg" alt="A password field and a confirm field showing the same row of Egyptian hieroglyphs, and a mistyped confirm field whose glyphs diverge from position 6" width="760"></p>

passglyph v2 replaces the masking dots in a password field and its confirm field with Egyptian hieroglyphs (a curated 64-sign set from U+13000–U+1342F). Matching passwords show the same sequence. A typo changes the glyphs from the first wrong character on. The glyphs are different on every page load.

It's designed so that it doesn't leak:

  • No per-character mapping. A character → glyph table would be a substitution cipher (balloon would show its repeated letters). Instead, glyph i is HMAC-SHA-256(key, password[0..i]) truncated to 4–6 bits (default 5, 32 glyphs). Repeated characters get unrelated glyphs.
  • Per-instance 128-bit random key, imported as a non-extractable WebCrypto key; the salt bytes are wiped. Without the key a screenshot or recording tells you nothing, and sessions or sites can't be correlated.
  • The real <input type="password"> stays exactly that. The glyphs are drawn on an aria-hidden canvas overlay aligned with the caret. It scrolls with the text and handles selection, paste, IME, RTL, font size and resizing. Autofill, password managers and screen readers work as usual.
  • No code points in the DOM. Glyphs come from a bundled bitmap sprite drawn with Path2D, so there's no font and no 𓂀 text. A DOM snapshot is identical for any two passwords.
  • No extra length or timing leak. One glyph per dot, one repaint per input, no per-keystroke flourish, and work that depends only on the length.
  • Falls back to normal dots if canvas, Path2D or WebCrypto is missing.
  • Accessible: a polite live region announces "Passwords match" or "Passwords don't match".
  • CSP-safe: no <style> injection, no eval, no network, no storage.

The honest part: each position shows about 5 bits under that session's key. If an attacker had the key, they could recover the password one character at a time. The key never leaves memory, and anything that could read it could read the input anyway. Inline mode also reveals where the first typo is (that's the point). The numbers are below and in SECURITY-DESIGN.md. Want the stricter v1 behaviour (one PBKDF2 glyph beside each field, ≤ 12 bits total)? Use mode: 'badge'.

Sizes (min+gzip): core 3.2 KB · hieroglyph sprite 3.8 KB (separate file, passglyph/hieroglyphs) · badge alphabet 1.3 KB (separate file, passglyph/glyph) · IIFE bundle with everything 8.2 KB. Zero dependencies, TypeScript types, ESM + CJS + IIFE. Vanilla JS, a <pass-glyph> web component, and React and Vue wrappers.

Install

npm i passglyph

CDN: <script src="https://unpkg.com/passglyph"></script> → window.passglyph.attach(...).

Usage

Vanilla

import { attach } from 'passglyph';

const detach = attach(
  document.querySelector('#password'),
  document.querySelector('#confirm'),
  { onChange: (match) => submit.disabled = match === false },   // mode: 'inline' by default
);
// later: detach();

Web component

<script type="module">import 'passglyph/element';</script>

<pass-glyph mode="inline" bits="5">
  <input type="password" autocomplete="new-password">
  <input type="password" autocomplete="new-password">
</pass-glyph>

<!-- or point it at existing inputs -->
<pass-glyph for="password" confirm="confirm"></pass-glyph>

It fires passglyph-change with event.detail.match (true / false / null).

React

import { useRef } from 'react';
import { usePassGlyph } from 'passglyph/react';

function SignUp() {
  const pw = useRef(null), confirm = useRef(null);
  usePassGlyph(pw, confirm, { mode: 'inline' });
  return <><input ref={pw} type="password" /><input ref={confirm} type="password" /></>;
}

Vue 3

<script setup>
import { ref } from 'vue';
import { usePassGlyph, vPassGlyph } from 'passglyph/vue';
const pw = ref(), confirm = ref();
usePassGlyph(pw, confirm);
</script>
<template>
  <input ref="pw" type="password" /><input ref="confirm" type="password" />
  <!-- or: <div v-pass-glyph="{ mode: 'badge' }"><input type="password"><input type="password"></div> -->
</template>

react and vue are optional peer dependencies. The wrappers are separate subpaths, so the core never imports them.

API

attach(password, confirm, options?) → detach()

OptionDefaultDescription
mode'inline''inline': hieroglyphs replace the dots. 'badge': one v1 glyph beside each field
bitsinline 5, badge 10Inline: bits per position, 4–6 (16/32/64 glyphs). Badge: total bits, 4–12. Clamped
minLength8Length before anything is announced (badge: before a glyph is shown). Minimum 4
debounce300ms of quiet before the announcement (badge: before the repaint). Minimum 100
sizeinline 1.1, badge 20Inline: glyph cell width in em. Badge: glyph size in px
iterations60000Badge only: PBKDF2 iterations (minimum 10,000)
budget250Badge only: fixed repaint deadline after the debounce
unstyledfalseDon't style the badge canvases or hide the live region (style .passglyph-sr yourself). The inline overlay is always positioned
labels{ match: 'Passwords match', mismatch: "Passwords don't match" }Live-region text
onChange—(match: boolean | null) => void
onFallback—(reason: 'canvas' | 'webcrypto') => void when passglyph falls back to native dots
mount, mountStatus—Custom placement of badge canvases / the live region
cryptoglobalThis.cryptoWebCrypto implementation
unsafeSalt—Testing only: fixed salt for reproducible sequences. Never use it in production

DOM added in inline mode: one <canvas class="passglyph-overlay" aria-hidden="true"> per input, a few CSSOM properties on the input (color, -webkit-text-fill-color transparent, caret-color, letter-spacing), and one <span class="passglyph-sr" role="status" aria-live="polite">. detach() restores the inputs exactly and removes everything.

Lower level

ExportDescription
createSalt(crypto?)"passglyph/v1" ‖ 16 random bytes; throws without a CSPRNG
instanceKey(salt, crypto?)Non-extractable HMAC-SHA-256 key; wipes salt
glyphSequence(key, value, bits?, crypto?)One index < 2^bits per code point, each from the HMAC of the prefix
hieroglyphPath(i), HIEROGLYPH_COUNTSprite path data (32×32 units) for drawing only
deriveIndex, glyphOps, neutralOps, paint, opsToSVG, clampBits …Badge mode building blocks (v1)

The trade-off, in numbers

Inline mode, per position (b = bits):

bglyphstypo missed at its positionmissed at it and the next 9with the key (never exposed): survivors per position
4166.25 %≈ 10^-12≈ 6 of 95
5323.1 %≈ 10^-15≈ 3 of 95
6641.6 %≈ 10^-18≈ 1.5 of 95

Without the key (screenshots, shoulder surfing, recordings, DOM snapshots) the sequence reveals nothing beyond the length the dots already showed, plus the position of the first mismatch. With the key it reveals about b bits per character, so treat it as near-full disclosure. That's why the key is non-extractable, per instance and memory-only. Badge mode reveals ≤ 12 bits in total, even with the salt.

Compared

passglyphChroma-Hashhashmask
Shows the hint inside the field✔ inline hieroglyphs (or badge)✖ bars beside✖ beside
Typo position visible✔ (inline)✖✖
Last activity20262018 (jQuery plugin, 2009 idea)2013 (Chrome extension; Arc90 demo 2009)
Per-session random salt✔ 128-bit✖ static developer-set salt (same colours every visit)✖ stable by design (to aid memorability)
Keyed derivation✔ HMAC, non-extractable key (inline); PBKDF2 60k (badge)✖ single MD5✖ fast hash
Entropy shown4–6 bits per position (inline); 4–12 total (badge)1–4 colour bars, 12 bits each (4-bit components)not capped
Nothing derived in DOM✔ canvas only✖ CSS colours✖
Accessible match announcement✔✖✖
Not colour-only✔ shape + mark✖partial
Short-input & timing protection✔✖✖
CSP-safe, zero deps✔jQuery✖
Web component / React / Vue✔✖✖

Limitations and trade-offs

  • Not a strength meter, and not protection against keyloggers, malicious extensions, XSS or compromised scripts: they can read the input directly.
  • Inline mode reveals where the first typo is, and is only as strong as the in-memory key (see above). Use mode: 'badge' for the v1 guarantees.
  • Caret alignment relies on matching the browser's mask-character width with letter-spacing. That's tuned for Chromium/WebKit (•) and Firefox (●). Exotic fonts may be off by a fraction of a pixel per character.
  • IME composition shows native dots until it's committed.
  • A typo in the very last character is caught only by that one glyph (1 − 2^-b), and the "Passwords don't match" announcement covers it.
  • Hieroglyphs are 1-bit 32×32 sprites derived from Noto Sans Egyptian Hieroglyphs (OFL-1.1, see NOTICE). Wide signs render small.
  • It's a hint, not verification. Still compare on submit.

Browser support

Every browser with WebCrypto subtle, canvas, Path2D and measureText: all current evergreen browsers. Without them you get plain dots. WebCrypto requires a secure context (HTTPS or localhost). On Node 18, pass crypto: require('node:crypto').webcrypto (only relevant for tests).

Upgrading from v1

attach() now defaults to mode: 'inline'. Pass mode: 'badge' (or <pass-glyph mode="badge">) to keep v1 behaviour. bits means bits per position in inline mode. A missing WebCrypto now falls back to dots instead of throwing.

Releasing

Publish a GitHub release for tag vX.Y.Z: release.yml tests, then publishes to GitHub Packages as @mrlouiswright/passglyph, and to npm with provenance once the NPM_PUBLISH repository variable is true.

Support

If this project is useful to you, you can support its development with a donation: Buy me a coffee. It helps keep these tools maintained, documented and free for everyone.

Optional credit link

MIT needs no attribution. If you want a small backlink next to the fields:

const detach = attach(pw, pw2, { credit: true });
// or: creditTarget: '#footer' / an HTMLElement

Style it yourself (no inline styles are set):

.passglyph-credit { font-size: 0.75rem; opacity: 0.65; }

The link text is fixed (Built with passglyph) — nothing derived from the password. detach() removes it. Off by default; no network calls or tracking.

Credit (optional)

Free to use under MIT, no attribution required. If passglyph helps you, a link back is appreciated:

<a href="https://louiswright.co.uk">Built with passglyph by Louis Wright</a>
[![Built with passglyph by Louis Wright](https://img.shields.io/badge/built%20with-passglyph-blue)](https://louiswright.co.uk)

License

MIT © Louis Wright (Louis Wright)