Tutorials /

How to password protect a static site on Vercel, Netlify, or any JAMStack site

Walkthrough, demo & code example

Oct 10, 2021 • 🍿🍿 8 min. read

Why is this hard?

Dynamic websites often have databases in which you can store things like user authentication, and dynamically show/hide content based on whether someone is logged in i.e. you have tools to manage user state.

Static sites are generated at build time and are usually just a folder of CSS, JavaScript and HTML on the server, so there's no concept of user state, and thus it's really hard to show or hide things.

Why do this?

Say you had a static site, wanted to generate a family photo album, a portfolio, or were working on a draft of something you only wanted to share with a few people, you might want to add a password to it.

There are several options already including htpassword files (not the nicest experience), and various provider-specific options, but I'm working on some blog posts I wanted to share with a few beta readers on my site and wanted to see whether it was possible to do this client-side.

Please Note that none of this should be a substitution for proper authentication - it's just a simple solution for non-critical data.

The answer: use StatiCrypt to encrypt static files

This uses a library called staticrypt which uses AES-256 to encrypt your HTML with your passphrase and put it in an HTML file with a password prompt that can decrypted in-browser (client-side). I use the CLI version on NPM.

How this works

  1. Eleventy builds your static site, as you would normally.
  2. Once build has run, you encrypt one or more static HTML files using a special command in your package.json build file - these are turned into a JS encrypted blob and the page is replaced with a password form.
  3. When someone browses to the page, the form is shown, and decrypts client side if the correct password is entered.
  4. On further requests to that page, JavaScript checks for a stored password in LocalStorage, and decrypts automatically. In practice this is fast enough for users to not notice.

The benefit of doing this with something like eleventy is you can build it into your build process, so that it only encrypts things in production, leaving you to work without needing to enter a password when running your site locally on your machine.

Use LocalStorage to save reentering a passphrase on page refresh

We still have a problem, however - if someone refreshes the page they will be prompted to log in again.

We solve this by setting the credentials (if correct) in LocalStorage against the site, then checking for this and decrypting on the fly on each page request (as I said, not a robust hacker-proof solution, but perfectly good for simple use cases!)

...Enough already - show me a demo and let me at the code!

I wanted a movie-related example, and I've recently rewatched Jurassic Park, with Samuel L Jackson and that famous password scene, so whipped up the following demo.

The password is 'please' :)

Demo - a page having been encrypted

example password form

Demo - what it looks like unencrypted

example password form

Warnings and things to be aware of

  • As encryption happens at build time for convenience, the password is stored in your package.json file, so this security is pointless if stored in publicly accessible repositories.
  • Anyone with access to your repository could see the password.
  • Keeping passwords in source control is a bad practice generally, but this is just meant to be a deterrent and a fun example.

How to set this up yourself:

1. Create a login form page template

Create a login form page, and add some client-side JS to check and compare the passphrase against the encrypted page.

I've put this in src/auth/login.html and configured eleventy to ignore this with a .eleventyignore file with the file name in to stop eleventy treating this as something to process, as it's only used by the encryption library.

src/auth/login.html

This contains the password form used, some JavaScript to handle password processing and error handling - any page encrypted uses this.

The bits to pay attention on are from line 99 downwards in the login form template - the crypto_tag placeholder then the script that does the hard work.

{crypto_tag}

<script>
const keySize = 256;
const iterations = 1000;
const formElement = document.getElementById('staticrypt-form');
let errorCount = 0;
const errors = [
'Uh Uh Uh, whats the magic word?',
'Sorry.',
'Caps lock?',
'Try again.',
'Incorrect.',
'That’s not right.',
'Wrong.',
];

function decrypt (encryptedMsg, pass) {
var salt = CryptoJS.enc.Hex.parse(encryptedMsg.substr(0, 32));
var iv = CryptoJS.enc.Hex.parse(encryptedMsg.substr(32, 32))
var encrypted = encryptedMsg.substring(64);

var key = CryptoJS.PBKDF2(pass, salt, {
keySize: keySize/32,
iterations: iterations
});

var decrypted = CryptoJS.AES.decrypt(encrypted, key, {
iv: iv,
padding: CryptoJS.pad.Pkcs7,
mode: CryptoJS.mode.CBC
}).toString(CryptoJS.enc.Utf8);
return decrypted;
}


function submitPass(passphrase) {
const encryptedMsg = '{encrypted}';
const encryptedHMAC = encryptedMsg.substring(0, 64);
const encryptedHTML = encryptedMsg.substring(64);
const decryptedHMAC = CryptoJS.HmacSHA256(encryptedHTML, CryptoJS.SHA256(passphrase).toString()).toString();

if (decryptedHMAC !== encryptedHMAC) {
document.querySelector('.instructions').innerHTML = errors[errorCount];
if (errorCount < (errors.length - 1)) {
errorCount += 1;
}
return;
}
// good pass, decrypt the page
const myStorage = window.localStorage;
myStorage.setItem('passphrase', passphrase);
const plainHTML = decrypt(encryptedHTML, passphrase);

document.write(plainHTML);
document.close();
};

// catch the form submission
formElement.addEventListener('submit', (e) => {
e.preventDefault();
const inputValue = document.getElementById('staticrypt-password').value;
submitPass(inputValue);
});

// auto-decrypt if the password is in local storage
document.addEventListener('DOMContentLoaded', () => {
const myStorage = window.localStorage;
if (myStorage.getItem('passphrase')) {
submitPass(myStorage.getItem('passphrase'));
}
});
</script>

2. Run encryption on files

This lives as a command in package.json (example) and is taken straight from the library docs.

"encrypt": "find ./_site/index.html -type f -name '*.html' -exec staticrypt {} please -o {} -f ./src/auth/login.html \\;",

It's a rather complicated looking command, but in reality it just finds a set of matching HTML files in the generated output, and runs encryption on them, turning the HTML into a big blob of encrypted JS, and injecting the login form and JS needed to unencrypt on the page using the template and password specified.

Once you've configured this as part of your build process, you can essentially forget it.

Encrypt a whole directory

If you wanted to run this on a whole directory of files, just replace find ./_site/index.html with find ./_site/my-folder in the command.

How to clear stored passwords locally

Delete the following from LocalStorage in devtools
build settings
...I'm sure it would be easy to do this in JS as a logout link.

Configure as part of your build process

To run this as part of a continuously-integrated build process, you could add the encrypt command to whatever host you use.

npm run build && npm run encrypt

Configure to run on Vercel

In this example, I've added it to Vercel as an override to the Eleventy Build command - the important thing is that it's run after you generate your site.

Vercel's build settings:
build settings

Conclusion

While I wouldn't use this professionally for any kind of serious content due to the caveats above, you can use this to password protect a single file, folder or subfolder of your site in a pinch easily.

I hope this was useful - people often write off static sites and JAMstack, but I'm continuously amazed by what's possible using them.

N.B. ❤️ Full credit to Adam Stoddard who had StatiCrypt working on his portfolio page - I was super curious and 'view-sourced' to find out about StatiCrypt and went from there!


Was this useful? →

← Back to Tutorials