Mod: PIN Gate
What it does
Replaces a password input with a 4-digit PIN entry screen for EPS web apps and PWAs. Features an on-screen numpad, dot indicators, auto-submit on the 4th digit, a shake animation on wrong PIN, and a Fibonacci lockout after 3 failed attempts. Designed to feel native on iOS.
Why this works
Keyboard-based password inputs are hostile in PWA standalone mode — they pop the keyboard, obscure the screen, and feel like a website, not an app. A fixed numpad keeps the UI fully in control of the app, works well with thumbs, and pairs naturally with iOS/Android PWA full-screen mode. Fibonacci lockout provides meaningful brute-force resistance without a fixed cap.
When to apply
Any EPS web app or PWA that needs a lightweight auth gate: terminal apps, dashboards, personal tools behind Tailscale. Not a replacement for production auth — ideal for personal apps where simplicity matters more than enterprise-grade security.
Implementation
1. Add CSS variables (if not already present)
The gate assumes these CSS custom properties exist in :root. Adjust values to match your app's palette.
css
:root {
--bg: #0d0d0d;
--surface: #161616;
--border: #2a2a2a;
--accent: #c9a84c;
--accent-dim: #7a6030;
--text: #e8e8e8;
--text-dim: #666;
}
2. Add gate CSS
#gate {
display: none;
position: fixed;
inset: 0;
background: var(--bg);
z-index: 100;
align-items: center;
justify-content: center;
}
#gate-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
width: 240px;
}
#gate-logo {
font-size: 18px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.1em;
text-align: center;
}
#gate-dots {
display: flex;
gap: 16px;
}
.gate-dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--border);
background: transparent;
transition: background 0.1s, border-color 0.1s;
}
.gate-dot.filled { background: var(--accent); border-color: var(--accent); }
.gate-dot.error { background: #e05252; border-color: #e05252; }
.gate-dot.locked { background: var(--text-dim); border-color: var(--text-dim); }
/* Shake animation — plays on wrong PIN */
@keyframes gate-shake {
0% { transform: translateX(0); }
15% { transform: translateX(-8px); }
30% { transform: translateX(7px); }
45% { transform: translateX(-6px); }
60% { transform: translateX(5px); }
75% { transform: translateX(-3px); }
90% { transform: translateX(2px); }
100% { transform: translateX(0); }
}
#gate-dots.shake {
animation: gate-shake 0.45s ease;
}
#gate-pad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
width: 100%;
}
.pad-key {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
font-size: 20px;
font-weight: 500;
height: 56px;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.1s;
-webkit-tap-highlight-color: transparent;
}
.pad-key:active { background: var(--accent-dim); }
.pad-key.pad-empty { visibility: hidden; pointer-events: none; }
.pad-key.pad-del { font-size: 16px; color: var(--text-dim); }
3. Add gate HTML (before your main app <div>)
<div id="gate">
<div id="gate-inner">
<div id="gate-logo">yourapp</div>
<div id="gate-dots">
<div class="gate-dot" id="dot-0"></div>
<div class="gate-dot" id="dot-1"></div>
<div class="gate-dot" id="dot-2"></div>
<div class="gate-dot" id="dot-3"></div>
</div>
<div id="gate-pad">
<button class="pad-key" data-digit="1">1</button>
<button class="pad-key" data-digit="2">2</button>
<button class="pad-key" data-digit="3">3</button>
<button class="pad-key" data-digit="4">4</button>
<button class="pad-key" data-digit="5">5</button>
<button class="pad-key" data-digit="6">6</button>
<button class="pad-key" data-digit="7">7</button>
<button class="pad-key" data-digit="8">8</button>
<button class="pad-key" data-digit="9">9</button>
<div class="pad-key pad-empty"></div>
<button class="pad-key" data-digit="0">0</button>
<button class="pad-key pad-del" id="pad-del">⌫</button>
</div>
</div>
</div>
4. Add gate JS
submitPin() starts your auth action but does not hide the gate — the gate only disappears once the server confirms success (e.g. onopen for a WebSocket). This prevents the UI from flashing through to the app for a split second on a wrong PIN.
let pin = '';
let failCount = 0;
let fibA = 1, fibB = 1;
let padLocked = false;
function nextFib() {
const val = fibA;
[fibA, fibB] = [fibB, fibA + fibB];
return val;
}
function updateDots(state) {
// state: 'normal' | 'error' | 'locked'
for (let i = 0; i < 4; i++) {
const dot = document.getElementById('dot-' + i);
dot.classList.toggle('filled', state !== 'locked' && i < pin.length);
dot.classList.toggle('error', state === 'error');
dot.classList.toggle('locked', state === 'locked');
}
}
function setPadDisabled(disabled) {
padLocked = disabled;
document.querySelectorAll('.pad-key').forEach(k => k.disabled = disabled);
}
function lockPad(seconds) {
pin = '';
setPadDisabled(true);
updateDots('locked');
const logo = document.getElementById('gate-logo');
const origText = logo.textContent;
let remaining = seconds;
logo.textContent = `try again in ${remaining}s`;
const t = setInterval(() => {
remaining--;
if (remaining <= 0) {
clearInterval(t);
logo.textContent = origText;
setPadDisabled(false);
updateDots('normal');
} else {
logo.textContent = `try again in ${remaining}s`;
}
}, 1000);
}
function showGate() {
pin = '';
if (!padLocked) updateDots('normal');
document.getElementById('gate').style.display = 'flex';
}
function hideGate() {
document.getElementById('gate').style.display = 'none';
}
function pinError() {
failCount++;
updateDots('error');
// Shake the dots
const dotsEl = document.getElementById('gate-dots');
dotsEl.classList.remove('shake');
void dotsEl.offsetWidth; // force reflow so re-triggering works
dotsEl.classList.add('shake');
if (failCount >= 3) {
failCount = 0;
const wait = nextFib();
setTimeout(() => lockPad(wait), 600);
} else {
setTimeout(() => { pin = ''; updateDots('normal'); }, 600);
}
}
function submitPin() {
// ── Replace this with your app's auth action ──
// Do NOT hide the gate here. Hide it only after the server confirms success.
// e.g. sessionStorage.setItem('token', pin); connect();
}
document.getElementById('gate-pad').addEventListener('click', (e) => {
if (padLocked) return;
const key = e.target.closest('.pad-key');
if (!key) return;
if (key.id === 'pad-del') {
pin = pin.slice(0, -1);
updateDots('normal');
return;
}
const digit = key.dataset.digit;
if (digit === undefined || pin.length >= 4) return;
pin += digit;
updateDots('normal');
if (pin.length === 4) submitPin();
});
// Show the gate on load
showGate();
5. Server-side enforcement (Node.js)
If your app authenticates via a server (e.g. WebSocket token), add IP-based Fibonacci lockout there too. Client-side lockout is UX; server-side is actual security.
const loginState = new Map(); // ip -> { failures, lockedUntil, fibA, fibB }
function getState(ip) {
return loginState.get(ip) || { failures: 0, lockedUntil: 0, fibA: 1, fibB: 1 };
}
function recordFailure(ip) {
const s = getState(ip);
s.failures++;
if (s.failures >= 3) {
const wait = s.fibA;
[s.fibA, s.fibB] = [s.fibB, s.fibA + s.fibB];
s.lockedUntil = Date.now() + wait * 1000;
s.failures = 0;
}
loginState.set(ip, s);
}
function recordSuccess(ip) { loginState.delete(ip); }
// Example: ws verifyClient callback form
verifyClient: ({ req }, cb) => {
const ip = req.socket.remoteAddress;
const s = getState(ip);
if (Date.now() < s.lockedUntil) { cb(false, 429, 'Too Many Requests'); return; }
const token = new URL(req.url, 'https://localhost').searchParams.get('token');
if (token === SECRET) { recordSuccess(ip); cb(true); }
else { recordFailure(ip); cb(false, 401, 'Unauthorized'); }
}
6. Wiring gate hide + pinError to server response (WebSocket pattern)
The key insight: onopen fires only if the PIN was correct. Track a wsConnected flag to distinguish a bad token (handshake never opened) from a network drop (connection opened then closed).
let wsConnected = false;
// In your connect() function:
ws.onopen = () => {
wsConnected = true;
hideGate(); // ← gate hidden here, not in submitPin()
// ... rest of setup
};
ws.onclose = () => {
const wasConnected = wsConnected;
wsConnected = false;
if (!wasConnected) {
// Handshake was rejected — bad token
sessionStorage.removeItem('token');
showGate();
pinError(); // ← shake + red dots
} else if (!intentionalClose) {
// Was connected, then dropped — reconnect silently
reconnectTimer = setTimeout(connect, 2000);
}
};
This pattern also means a network blip while you're logged in will silently reconnect rather than kicking you back to the PIN screen.
Customization
- PIN length — change the
4inpin.length >= 4and add/remove.gate-dotelements - Attempt limit — change
>= 3inpinError()to any number - Logo text — set
#gate-logocontent to your app name - Colors — override
--accent,--surface,--borderin:root - Key size — adjust
height: 56pxandfont-size: 20pxon.pad-key - Shake intensity — adjust the
translateXvalues in@keyframes gate-shake
Guidelines
- Store the PIN server-side in an env var, not in client code
- Never hide the gate in
submitPin()— only hide it on confirmed server success - Call
showGate()whenever the session expires or the user logs out - Do not call
haptic()onpinError— the red flash + shake is feedback enough; haptic on success only
Notes
- Auto-submits on the 4th digit — no submit button needed
- Lockout sequence: 3 fails → 1s, next 3 → 1s, next 3 → 2s, 3s, 5s, 8s, 13s…
void dotsEl.offsetWidthforces a reflow so the shake animation re-triggers on repeated wrong PINspadLockedguard prevents race conditions if the user taps during the error animation- Works in PWA standalone mode — no keyboard, no native popups
- Reference implementation: palantir