Implementing simple, secure passphrase authentication in Next.js 10 and Vercel
I'm working on publishing some private case studies on my personal website at the moment and wanted a simple way of adding passphrase security.
I'm working on publishing some private case studies on my website at the moment and wanted a simple way of adding passphrase security.
There are a few notable libraries that handle authentication in Next.js, but I had two requirements that weren't really handled by these libraries:
I wanted passphrase security, not password authentication. Password authentication is commonly tied to an email and requires an identity to be set up. The method I'm wanting just relies on a simple passphrase that I would have given to the user.
I wanted to store the passphrases in environment variables. Not committing them to GitHub, storing them in Prismic (where the rest of my content is) would be absolutely ideal.
I don't want to store cookies on the user's computer. I'm aiming to stay cookie-less at the moment, which is why I use Fathom Analytics on all my websites.
After a bit of tinkering, I figured out a relatively simple solution that I figured I'd share!
First up is the front-end. Fundamentally, access to the case study can be controlled by something as simple as a state.
import type { GetStaticProps, GetStaticPaths } from 'next';
import { useState } from 'react';
type ICaseStudy = {
uid: string;
};
const CaseStudy = ({ uid }: ICaseStudy) => {
const [authenticated, setAuthenticated] = useState<boolean>(false);
const [passphrase, setPassphrase] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [data, setData] = useState<any>(null);
return (
<form
className={`${styles.form} ${loading ? styles.loading : ''}`}
onSubmit={authenticate}
>
<label className={styles.label} htmlFor="passphrase">
Enter passphrase
</label>
<input
required
id="passphrase"
className={styles.input}
type="text"
placeholder="Enter a passphrase"
value={value}
onChange={({ target }) => setPassPhrase(target.value)}
/>
<button aria-label="Sign up" type="submit" className={styles.button}>
→
</button>
</form>
);
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const { uid } = params!;
return {
props: {
uid,
},
};
};
export const getStaticPaths: GetStaticPaths = async () => {
const caseStudies = await queryAt('document.type', 'case_study');
const paths = caseStudies.map(({ uid }) => ({ params: { uid } }));
return {
paths,
fallback: false,
};
};
Two things to note here: firstly, I wasn't able to fetch my Prismic data normally in getStaticProps. This is because Next.js stringifies the data and outputs it in the raw HTML, meaning the case study data is viewable by anyone that knows how to right click > view source.
Secondly, basically the same thing except for the passphrase. Verification of the passphrase needs to happen server-side, which is why we're using a form submission. If we validated the passphrase in React, the passphrase would appear somewhere in the HTML source.
Okay so next up, a simple authentication function:
import type { FormEvent } from 'react';
import { queryAt } from '../../utils/prismic';
async function authenticate(event: FormEvent) {
event.preventDefault();
setLoading(true);
try {
const response = await fetch('/api/passphrase', {
method: 'post',
body: JSON.stringify({ uid, passphrase }),
});
const body = await response.json();
if (!body.success) {
throw new Error(body.message);
}
const newData = await queryAt('my.case_study.uid', uid);
setData(newData.data);
setAuthenticated(true);
} catch (error: any) {
window.alert('Sorry, wrong password.');
} finally {
setLoading(false);
}
}
Here we're simply hitting up a Next.js API route at /api/passphrase
with the UID (case study slug) and passphrase. If that's successful, we'll fetch the data from Prismic (uses a small Prismic utility library).
Next up, the API route which verifies the passphrase:
export default async function handler(req, res) {
res.setHeader("Content-Type", "application/json");
try {
const { uid, passphrase } = JSON.parse(req.body);
if (!uid || !passphrase) {
throw new Error('Please provide a passphrase.');
}
const secret = process.env[`PASSPHRASE_${uid.toUpperCase()}`];
if (!secret) {
throw new Error('Passphrase has not been set up for this project.');
}
if (secret !== passphrase) {
throw new Error("Passphrase is not correct.");
}
res.statusCode = 200;
res.json({ success: true }));
} catch (error) {
res.statusCode = 500;
res.json({ message: error.message }));
}
}
Here we're receiving the UID and passphrase, then fetching the appropriate environment variable based on the UID. After some error checking, we can verify the passphrase then return a success message to the browser.
Last but not least - just jump onto Vercel and enter those environment variables in Settings.
And that's it! Simple passphrase verification in Next.js. I'm sure I've missed a gaping security issue somewhere like hijacking the request and returning a success message anyway, but it'll do for now.
Let me know if you have any tips on how to improve it!