<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Nodejs on blog.ntcu.be</title><link>https://blog.ntcu.be/tags/nodejs/</link><description>Recent content in Nodejs on blog.ntcu.be</description><image><title>blog.ntcu.be</title><url>https://blog.ntcu.be/og-image.png</url><link>https://blog.ntcu.be/og-image.png</link></image><generator>Hugo</generator><language>en-us</language><copyright>Jens · RSS</copyright><lastBuildDate>Tue, 13 Oct 2026 20:55:00 +0200</lastBuildDate><atom:link href="https://blog.ntcu.be/tags/nodejs/index.xml" rel="self" type="application/rss+xml"/><item><title>Building a self-hosted password generator with one-time secret links</title><link>https://blog.ntcu.be/posts/password-generator-secret-sharing/</link><pubDate>Tue, 13 Oct 2026 20:55:00 +0200</pubDate><guid>https://blog.ntcu.be/posts/password-generator-secret-sharing/</guid><description>A small Docker app that generates strong passwords and shares them through links that expire or self-destruct. Secrets are encrypted in the browser, so the server can&amp;rsquo;t read them.</description><content:encoded><![CDATA[<p>Sending a password over email or Teams means it sits in someone&rsquo;s mailbox (and
a backup, and a search index) forever. I wanted something simple to hand
credentials to customers and colleagues: generate a strong password, get a link,
and have that link stop working after it&rsquo;s been read.</p>
<p>So I built one. You can try it at
<strong><a href="https://password.ntcu.be/">password.ntcu.be</a></strong>.</p>
<h2 id="what-it-does">What it does</h2>
<ul>
<li><strong>Generates passwords in the browser</strong> with <code>crypto.getRandomValues()</code>.
You pick the length and character sets; the generated password never touches
the server unless you choose to share it.</li>
<li><strong>Shares a secret as a link</strong>, with either:
<ul>
<li>an expiry time,</li>
<li><strong>one-time</strong> access (deleted after the first read), or</li>
<li>a <strong>view limit</strong> (1 to 50 views).</li>
</ul>
</li>
<li><strong>Encrypts in the browser.</strong> The server only ever sees ciphertext; the key
lives in the link itself.</li>
<li><strong>Custom branding</strong> through environment variables (logo, colours, title), so
it can look like your company&rsquo;s tool.</li>
</ul>
<h2 id="architecture">Architecture</h2>
<p>Three containers in a Docker Compose stack:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">internet ──► nginx ──► app (Node.js / Express) ──► Redis
</span></span><span class="line"><span class="cl">            (public)   (internal network only)     (internal network only)
</span></span></code></pre></div><p>The <code>app</code> and <code>redis</code> containers sit on a Docker network marked
<code>internal: true</code>, so they have no route to the outside world at all. Only nginx
is exposed.</p>
<h2 id="encryption-the-server-cant-read-your-secrets">Encryption: the server can&rsquo;t read your secrets</h2>
<p>The secret is encrypted <strong>in the browser</strong>, with the Web Crypto API, before
anything is sent. Each secret gets its own random 256-bit AES-GCM key:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="kr">async</span> <span class="kd">function</span> <span class="nx">encryptForLink</span><span class="p">(</span><span class="nx">plaintext</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">keyBytes</span> <span class="o">=</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">getRandomValues</span><span class="p">(</span><span class="k">new</span> <span class="nx">Uint8Array</span><span class="p">(</span><span class="mi">32</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">iv</span> <span class="o">=</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">getRandomValues</span><span class="p">(</span><span class="k">new</span> <span class="nx">Uint8Array</span><span class="p">(</span><span class="mi">12</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">key</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">subtle</span><span class="p">.</span><span class="nx">importKey</span><span class="p">(</span><span class="s2">&#34;raw&#34;</span><span class="p">,</span> <span class="nx">keyBytes</span><span class="p">,</span> <span class="s2">&#34;AES-GCM&#34;</span><span class="p">,</span> <span class="kc">false</span><span class="p">,</span> <span class="p">[</span><span class="s2">&#34;encrypt&#34;</span><span class="p">]);</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">subtle</span><span class="p">.</span><span class="nx">encrypt</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nx">name</span><span class="o">:</span> <span class="s2">&#34;AES-GCM&#34;</span><span class="p">,</span> <span class="nx">iv</span><span class="p">,</span> <span class="nx">additionalData</span><span class="o">:</span> <span class="nx">AAD</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nx">key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">new</span> <span class="nx">TextEncoder</span><span class="p">().</span><span class="nx">encode</span><span class="p">(</span><span class="nx">plaintext</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">ciphertext</span><span class="o">:</span> <span class="p">{</span> <span class="nx">v</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">iv</span><span class="o">:</span> <span class="nx">toBase64Url</span><span class="p">(</span><span class="nx">iv</span><span class="p">),</span> <span class="nx">data</span><span class="o">:</span> <span class="nx">toBase64Url</span><span class="p">(</span><span class="k">new</span> <span class="nx">Uint8Array</span><span class="p">(</span><span class="nx">data</span><span class="p">))</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nx">linkKey</span><span class="o">:</span> <span class="nx">toBase64Url</span><span class="p">(</span><span class="nx">keyBytes</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Only the ciphertext goes to the server. The key goes into the share link, after
the <code>#</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://password.ntcu.be/3f2a…c91#k3Y9xQ…
</span></span><span class="line"><span class="cl">                         └─ id ─┘ └─ key: never sent to any server
</span></span></code></pre></div><p>Browsers never send the part after <code>#</code> (the <em>fragment</em>) in a request. Not to my
server, not to Cloudflare in front of it, not to the reverse proxy, and it
doesn&rsquo;t end up in any access log. The recipient&rsquo;s browser fetches the
ciphertext by id, takes the key from its own address bar and decrypts locally.
After a successful reveal, the key is removed from the address bar.</p>
<p>The server adds its own AES-256-GCM layer on top before storing it in Redis,
so a stolen Redis dump is double-encrypted noise.</p>
<p>The one thing this can&rsquo;t protect against is a compromised server serving
<em>different JavaScript</em>. That&rsquo;s true for every web-based tool like this
(PrivateBin, Yopass, Bitwarden Send). A strict Content-Security-Policy and
zero third-party scripts keep that surface small.</p>
<h2 id="click-to-reveal">Click to reveal</h2>
<p>Opening a link doesn&rsquo;t fetch anything. The recipient sees <em>&ldquo;Someone shared a
secret with you&rdquo;</em> and a <strong>Reveal secret</strong> button. That matters more than it
sounds: Outlook Safe Links, Teams and Slack previews &ldquo;visit&rdquo; links before a
human does. If opening the page fetched the secret, a one-time link would be
burned by a bot before the real recipient ever saw it.</p>
<h2 id="let-redis-do-the-expiring">Let Redis do the expiring</h2>
<p>Every secret is stored with Redis&rsquo;s <code>EX</code> option, so expiry isn&rsquo;t something my
code has to remember to clean up: when the TTL runs out, the key is simply gone.</p>
<ul>
<li><strong>One-time</strong> secrets are deleted as part of the read itself.</li>
<li><strong>View-limited</strong> secrets carry a counter; each read decrements it (keeping
the remaining TTL), and the last read deletes the key.</li>
</ul>
<p>Reading and consuming a view happen in one small Lua script, which Redis runs
atomically. Two requests hitting a one-time link at the same moment can&rsquo;t both
get the secret: fire 10 in parallel and you get exactly one <code>200</code> and nine
<code>404</code>s.</p>
<p>Persistence is optional. With AOF enabled, secrets survive a container restart.
With it disabled, a restart wipes everything, which for some people is a
feature.</p>
<h2 id="hardening">Hardening</h2>
<ul>
<li><strong>Two layers of rate limiting</strong> per client IP: nginx (<code>limit_req</code>, 10
requests/s with a small burst, answering <code>429</code>) in front of
<code>express-rate-limit</code> in the app. The request path is Cloudflare → Nginx Proxy
Manager → the app&rsquo;s nginx, so nginx&rsquo;s <code>realip</code> module recovers the visitor&rsquo;s
IP from <code>X-Forwarded-For</code>, trusting only private addresses and
<a href="https://www.cloudflare.com/ips/">Cloudflare&rsquo;s ranges</a>. Otherwise every
visitor would share one rate-limit bucket.</li>
<li><strong>Strict headers</strong>: Helmet plus nginx set a tight Content-Security-Policy (no
inline scripts, no framing), <code>nosniff</code>, <code>no-referrer</code>.</li>
<li><strong><code>Cache-Control: no-store</code></strong> on secret responses, so they don&rsquo;t end up in a
browser or proxy cache.</li>
<li><strong>Boring 404s</strong>: secret IDs are UUIDv4 and validated; invalid, expired and
already-read secrets all return the same <code>404 Not found</code>.</li>
<li><strong>Input limits</strong>: 4096 characters per secret, 1 MB request bodies, expiry
capped at 30 days.</li>
</ul>
<h2 id="deployment">Deployment</h2>
<p>It runs on my homelab Docker host and deploys with the same push-to-deploy
pipeline as everything else (see
<a href="https://blog.ntcu.be/posts/self-hosted-git-and-ci/">Self-hosting my code</a>): a push to <code>main</code>
builds the image on my runner, pushes it to my Forgejo registry and the Docker
host pulls it.</p>
<h2 id="whats-next">What&rsquo;s next</h2>
<ul>
<li><strong>An optional passphrase</strong>, sent through a second channel (SMS, a call), so
a leaked link alone isn&rsquo;t enough. The ciphertext already carries a version
number, so this can be added without breaking existing links.</li>
</ul>
<p>It&rsquo;s a small project, but it replaced a lot of &ldquo;I&rsquo;ll send the password in a
separate email&rdquo; moments.</p>
]]></content:encoded></item></channel></rss>