Build an authentication service with Netlify Functions
February 08, 2020
In this post I will show you how to build a mini authentication service. It’s mini because we won’t add things such as email confirmation, password reset and external login providers. What we will have though is an email and password based stateless authentication service suitable for serverless functions.
By stateless I mean that we will generate a token, in our case a JSON Web Token (JWT), which conveys all required information to authenticate a request.
We will also build a small React application that uses the authentication service. You can see a demo of the final application here.
We will use Netlify functions, which are just AWS serverless functions in disguise. Netlify has greatly simplified the process of building an API and we’ll be able to deploy our serverless functions along with our app by simply pushing to a Git repository.
Instead of just giving step-by-step instructions, I want you to understand the underlying logic. If you only care about the end result, you can fork the repository here, but if you want to understand what you’re doing, read on.
JSON Web Token (JWT)
Traditionally you would maintain a session on the server, usually in the form of a random identifier, called the Sesssion ID, which maps to the logged in user. This identifier is sent on every request from the client, so we know which user it’s coming from. The mapping from session ID to user require some kind of datastore, hence making the authentication stateful. This means that our serverless function would have to query a database every time we need to check if the user is authenticated.
However with JWT, we can achieve stateless authentication. I want to explain how token-based authentication works, with a little story:
Token Teahouse
You are the proud owner of a very popular teahouse, Token Teahouse. It has thousands upon thousands of members and new people join all the time. A new face appears at the door, her name is Alice, and she wants to be a part of Token Teahouse. You welcome her, and ask for a password, so you can verify her membership at a later time if needed. After writing down the details in a very very long list, you fabricate a special piece of paper for her.
It states that Alice is a level 1 tea-sipper and has access to two cups of green tea. The paper is signed by you, so you will know it’s authentic.
The following day, Alice is at the door again. A thousand people have already visited since she joined, so you don’t recognize her. Alice claims that she is a level 1 tea-sipper and she shows you the paper to prove it. Sure enough, that’s your signature, so you let her in. That only took a few seconds. This is the secret to the success of your amazing teahouse. Not spending too much time verifying you customers membership, is the key to scale any great teahouse.
Another person, vaguely familiar appears at the door. He claims to be Bob, level 7 tea-downer, but he has no paper to prove it. Unfortunately Bob forgot to take the paper out of his trousers when washing them. Bob looks a bit ashamed, but you cheer him up: “No biggy Bob! This is why I keep a list of all my members. It will take a while, but we will sort it out”. Bob gives you his password, and you start going through the members list to verify if this really is Bobs password.
After a few minutes, you find Bob in the list and sure enough, Bob destroyed the paper but he remembered his password. You fabricate a new piece of paper for Bob, and let him in.
If only there was a way for a server to verify the authenticity of a message, to be sure that it was made by a specific entity and that is has not been tampered with. Then we can create something similar to the signed paper stating the tea-level of our customers. There is, it’s called Digital Signature.
A JSON web token (JWT) is just an open standard for such a piece of paper. It’s a way to securely transmit information in the form of a JSON object, and it’s usually signed with the algorithm HMAC, RSA or ECDSA.
The cool thing about RSA and ECDSA is that it’s asymmetric; the key used to sign is different from the key used to verify. If this is news to you, I recommend that you go watch Computerphile’s video on public key cryptography.
Using public key cryptography, we can have a private key known only to our authentication service, and a public key available to anyone. Any service we create will be able to check the authenticity of a user, without having to know the private key, hence greatly reducing the risk of losing it.
Here’s an overview for you:
The diagram above was inspired by this answer on StackOverflow.
JWT controversy
I asked for feedback on my Teahouse analogy on Reddit and accidentally sparked a controversial but healthy discussion on using JWTs for sessions.
The main problem with the approach we’re taking is that it’s hard to revoke JWTs, in case we want to block a user. This is a definitely a must if someone is hacked for example. To do this we would need to keep a blacklist of JWTs, available to all our services. Before authenticating we would have to check the blacklist, which suddenly makes our authentication stateful. Making stateful authentication is not the end of the world, but it’s suddenly less suitable for a serverless function.
This is another reason not to roll your own authentication, but use something like Auth0 or Firebase. Completing this tutorial though, will still give you some insight into JSON web tokens even if the final authentication service is incomplete.
The web app
We will build a very simple signup/login flow in React. One of the cooler parts is that we’ll create a convenient
useAuth
hook, to get the currently authenticated user from any component. Here is a snippet, showing how clean this hook makes our components:
import React from "react"import { UnauthenticatedApp } from "./UnauthenticatedApp"import { AuthenticatedApp } from "./AuthenticatedApp"import { useAuth } from "./providers/auth-provider"function App() {const { user } = useAuth()return user ? <AuthenticatedApp /> : <UnauthenticatedApp />}export default App
We borrow this technique from Kent C. Dodds, check out Kents blog post about authentication in React apps. The hook also provides a login, logout and signup function. Here’s how our Login component will look:
export function Login() {const { login } = useAuth()const inputs = [{name: "email",type: "email",},{name: "password",type: "password",},]return (<div><Form title="Login" onSubmit={login} inputs={inputs} /><p>Don't have an account? <Link to="/signup">Sign up here</Link></p></div>)}
Getting started
We will start by setting up a repository and have Netlify publish our application.
Initialize the repo with create-react-app
We will bootstrap our React application with create-react-app. It lets you set up a React project with a single command. All you have to do is run:
npx create-react-app amazing-jwt
create-react-app initializes a Git repo for you. You can now push this repository to GitHub, so we can deploy it easily on Netlify. Go ahead and create a new repo on GitHub, but make sure you DON’T create a README and DON’T select .gitignore or license. GitHub even tells us to skip this step if we’re importing an existing repository:
Now just add the remote and push the code as described in your new empty repo on GitHub.
git push -u origin master
Deploy on Netlify
Note that Netlify’s UI might have changed since I wrote this, but the process should be similar.
Sign up on https://www.netlify.com if you haven’t already. Then press the “New site from Git”. Now just follow Netlify’s helpful instructions to connect your GitHub repository.
Netlify has automatically detected that we’re using create-react-app, it therefore knows what our build command is and what directory it outputs to. All we have to do is press “Deploy site”. Netlify is now building your app, and a link should show when it’s done. In fact, it will now build and publish the app every time you push to the master branch in your Git repository. Simply amazing! Thanks Netlify, for making this so easy 🙌
Set up Netlify Functions workflow
Note that I use the term serverless functions and Netlify Functions interchangeably. Netlify Functions is just a specific vendor of serverless functions.
We want to transpile our Netlify Functions, so we can use ES6 features. We’ll need an extra bit of configuration, but it’s really minimal. Luckily we don’t have to write any Webpack configs.
In the root of the repo, create a file called netlify.toml with the following content:
[build]functions = "built-lambda"
This tells Netlify where to look for our serverless functions. We then install two dev dependencies, which we will use to transpile them.
npm i netlify-lambda npm-run-all -D
npm-run-all is a tool to run multiple npm-scripts (scripts in our package.json) in parallel. We use it to build our client app and our serverless functions in parallel.
netlify-lambda is a helper tool to build/transpile our serverless functions.
We need to adjust the current build script in our package.json file, so we build both the client code and the serverless API. Update your package.json file, so you have the following scripts:
"build": "run-p build:**","build:app": "react-scripts build","build:lambda": "netlify-lambda build src/lambda",
In your src folder, create a lambda directory. This is where we will place our serverless functions.
Let’s try to add a simple function, to see if it works. Add the following to src/lambda/hello.js:
export async function handler() {return {statusCode: 200,headers: {"Content-Type": "text/plain",},body: "Hello There!",}}
Another thing that you will want to do is add /built-lambda
to the .gitignore file.
If you push your changes to GitHub, a new build will be triggered by Netlify. After pushing to GitHub, open your sites dashboard on Netlify. When the build is done, press the Functions tab. You should see the Hello function there. If you press it, it will show you the URL, it looks something like:
https://SOMETHING.netlify.com/.netlify/functions/hello
It should say “Hello There!” if you open the link in your browser. Easy right? Congratulations if this was your first serverless function!
Before we start developing the authentication API, we want to be able to run our functions locally. It sucks if we have to deploy them every time just to test the code. Thankfully this is also very easy. With Netlify Dev we can run their entire platform locally.
Start by installing the Netlify CLI as a global package:
npm install netlify-cli -g
Then run:
netlify dev
It should show you a message similar to:
┌─────────────────────────────────────────────────┐│ ││ ◈ Server now ready on http://localhost:8888 ││ │└─────────────────────────────────────────────────┘
If you open http://localhost:8888 you should see the application. Now try going to localhost:8888/.netlify/functions/hello. You should see the “Hello There!” message again.
Netlify Dev just did A LOT of work for us! Try reading the output in the terminal, and you will realize how much this tool helps us.
It detects we’re using create-react-app:
◈ Starting Netlify Dev with create-react-app
It detects that we’re using netlify-lambda to build our functions, so it runs the build script:
◈ Function builder netlify-lambda detected: Running npm script build:lambda
It runs react-scripts start, because it knows we’re using create-react-app. It even routes 404 requests to index.html, so we can do client side routing:
$ react-scripts startℹ 「wds」: Project is running at YOUR_IP_ADDRESSℹ 「wds」: webpack output is served from /ℹ 「wds」: Content not from webpack is served from WHERE_YOU_PLACED_YOUR_REPO/amazing-jwt/publicℹ 「wds」: 404s will fallback to /index.html
It starts a server for the serverless functions, which even has hot reloading:
◈ Lambda server is listening on 34567
You will see in the output that the React app started on port 3000 and the lambda server on port 34567 in my case, so why does it say Server now ready on http://localhost:8888
?
Well, it actually sets up a proxy so we can send requests to the client and our serverless functions on the same port!
This is exactly how it works in production, so being able to simulate this locally is great. It means that anytime we want to
send a request from our app to a serverless function, we simply write a relative path:
const response = await fetch(`/.netlify/functions/logout`)
We don’t care what port the serverless function is running on, which saves us from some extra configuration.
Creating the API
We will now build the authentication API using Netlify Functions. We will have three serverless functions: /signup
, /login
and /logout
. Besides that we
will create a /user
function, just to demonstrate how to create a protected endpoint.
Signup
To get started, I’ll show you the required steps as a bunch of comments. We’ll then implement the code for each step. Take a moment and carefully read each step, try to understand the purpose and why that step is necessary.
// 1. Connect to the database and get a reference to the `users` collection// 2. Get the email and password from the request body// 3. Check to see if the user already exists, if so return error (409 Conflict)// 4. Get a salted hash of the password// 5. Insert the email and the hashed password in the `users` collection// 6. Get the secret key, used to sign the JWT, from an environment variable// 7. Create a JWT with the registered user and email as the payload// 8. Serialize the JWT in a secure http-only cookie// 9. Return the user id and a Set-Cookie header with the JWT cookie
Connecting to the database
We obviously need somewhere to store the registered users. The choice of database is rather arbitrary, since it’s only a small part of this blog post and our requirements are very limited. I chose MongoDB, since MongoDB Atlas has a free plan that can get us up and running in no time.
Note that MongoDB Atlas’ UI might have changed since I wrote this, so I’m intentionally a bit vague in describing these steps and won’t use any screenshots.
Go to https://cloud.mongodb.com and register a new account. Press “Create a cluster” on the free plan and select the cloud provider and region of your choice.
After creating a cluster on mongoDB Atlas, press the CONNECT button. We need to whitelist IP addresses that can access the database. Because we’re hosting our serverless functions
on Netlify, we don’t know what IP address will be used, so we whitelist all IP addresses. Simply add 0.0.0.0/0
to the IP address whitelist.
You will also be asked to create a user for the database, I usually go with the username “admin”, but feel free to add whatever credential you want. We will use the credential in the next step, so remember it.
MongoDB will now ask you how you want to connect, choose “Application”. It will show a connection string, similar to:
mongodb+srv://admin:<password>@cluster0-1mz6n.mongodb.net/test?retryWrites=true&w=majority
We will add the database password as an environment variable, so we don’t commit sensitive information to the repo.
In the Netlify UI, open your project and go to Settings -> Build & Deploy -> Environment.
Now add a new environment variable with the name MONGODB_PASSWORD
and write the password as the value.
Our serverless functions will now have access to this value when running in production. But what if we want to be able to use our serverless functions locally? Well, remember when I said that Netlify Dev runs their entire platform locally. This also includes environment variables, all we need to do is link our local git repo.
Run the following command to link your local repo with Netlify:
netlify link
When it asks you how you want to link the folder to a site, just choose “Use current git remote origin”.
If you run netlify dev
again after linking, you will notice the following message:
◈ Injected build setting env var: MONGODB_PASSWORD
Dotenv
While it’s great that Netlify makes this so easy, in a real project we would probably have different environments for production and development. Hence we would probably use something like dotenv to set some local environment variables with different values for development.
Next we need to use the MongoDB package from NPM to connect to the database, run:
npm i mongodb
Finally we can write the code for step 1. We know that both signup and login will need to connect to the database, so let’s create a helper that they can both make use of. Add the following to a new file in src/helpers/db-helper.js
import { MongoClient } from "mongodb"const dbName = "jwt-authentication-example"function createClient() {const client = new MongoClient(// REPLACE WITH YOUR CONNECTION STRING`mongodb+srv://your-username:${process.env.MONGODB_PASSWORD}@cluster0-abcdef.mongodb.net/test?retryWrites=true&w=majority`,{ useNewUrlParser: true, useUnifiedTopology: true })// We add a usersCollection function to the client object,// this way neither login or signup need to know the name// of the database or the users collection.client.usersCollection = function() {return this.db(dbName).collection("users");}return client}export { createClient }
Create a new serverless function in src/lambda/signup.js and start with the basic structure:
export async function handler(event) {let errorStatusCode = 500try {} catch (err) {return {statusCode: errorStatusCode,body: JSON.stringify({ msg: err.message }),}} finally {}}
We can now add the code that performs “step 1” from the comments:
import { createClient } from "../helpers/db-helper";export async function handler(event) {const dbClient = createClient()let errorStatusCode = 500try {// 1. Connect to the database and get a reference to the `users` collectionawait dbClient.connect()const users = dbClient.usersCollection()} catch (err) {...} finally {// Remember to close the database connectiondbClient.close()}}
Getting the email and password from the body is pretty simple. The request body is given to us in the event
parameter:
// 2. Get the email and password from the request bodyconst { email, password } = JSON.parse(event.body)
We can now check if this user already exists:
// 3. Check to see if the user already exists, if so return error (409 Conflict)const existingUser = await users.findOne({ email })if (existingUser !== null) {errorStatusCode = 409throw new Error(`A user already exists with the email: ${email}`)}
You should never store a password in plain-text. By storing a hash of the password, we won’t even know the users password. Even if the database is compromised, the attacker will not know the passwords either. We will add a random salt, to ensure that two users with the same password will have different hashes. Otherwise you could use a rainbow table, which is essentially a way to reverse a hash function for a finite amount of known inputs.
Hashing the password with a random salt is easily done, when using the package bcrypt. Start by running:
npm i bcryptjs
Then step 4 is just:
import bcrypt from "bcryptjs";...// 4. Get a salted hash of the passwordconst passwordHash = await bcrypt.hash(password, 10);
Now we just insert the email and the hashed password in the users collection. When inserting the object, MongoDB generates a unique ID for us:
// 5. Insert the email and the hashed password in the `users` collectionconst { insertedId } = await users.insertOne({email,password: passwordHash,})
Before we continue implementing the signup function, we need a key to sign the JWT. We will use a 4096bit RSA public/private key-pair. To generate such a key, you will need ssh-keygen and openssl, which should already be installed if you’re on Mac or Linux. Here’s the commands to generate the key-pair (thanks to ygotthilf on GitHub):
ssh-keygen -t rsa -P "" -b 4096 -m PEM -f jwtRS256.keyopenssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub# This is your private keycat jwtRS256.key# This is your public keycat jwtRS256.key.pub
Remember that the private key is used to sign the JWT when we generate the token, and the public key is used to verify that the token is valid.
We now have a challenge, because we want to add the private key as an environment variable on Netlify. Environment variables on Netlify can’t contain line-breaks, but our key is multiple lines.
To fix this open the private key in a text editor and remove the lines that say -----BEGIN RSA PRIVATE KEY-----
and -----END RSA PRIVATE KEY-----
.
Then run the following command:
tr -d '\n' < jwtRS256.key > jwtRS256-no-line-breaks.key
The file jwtRS256-no-line-breaks.key
now contains your private key without the BEGIN and END markers and without any line-breaks. This is suitable to put into an environment variable.
Create a new environment variable on Netlify with name JWT_SECRET_KEY
containing the contents of jwtRS256-no-line-breaks.key
. We can now proceed with generating the JWT.
We will encapsulate the steps for creating the JWT cookie into a separate function, since it will also be used when the user logs in. Create a new file src/helpers/jwt-helper.js with the following contents:
/** Generate a JWT with the user ID and email as the payload,* then serialize to a secure HTTP-only cookie.*/function createJwtCookie(userId, email) {}export { createJwtCookie }
To implement this function, we need to install two dependencies. One for generating the JWT and another for serializing a cookie.
npm i jsonwebtoken cookie
Start by importing the dependencies and get the secret key from the environment variable:
import jwt from "jsonwebtoken"import cookie from "cookie"function createJwtCookie(userId, email) {// 6. Get the secret key, used to sign the JWT, from an environment variableconst secretKey ="-----BEGIN RSA PRIVATE KEY-----\n" +process.env.JWT_SECRET_KEY +"\n-----END RSA PRIVATE KEY-----";}
It’s important that we bring back the BEGIN and END markers and that they are on their own line.
Next up, we generate the JWT by calling jwt.sign
:
// 7. Create a JWT with the registered user and email as the payloadconst token = jwt.sign({ userId, email }, secretKey, {algorithm: "RS256",expiresIn: "100 days",})
We let the token expire after 100 days, it’s up to you to choose how long the token should be valid. The expiration can be thought of as a second line of defense. If a users token is compromised, it’s nice to know that it doesn’t last forever. If you we’re building a banking platform, you might set the expiry to 15 minutes. If you’re building a mobile app, it might never expire.
Finally we create a cookie and return it:
// 8. Serialize the JWT in a secure http-only cookieconst jwtCookie = cookie.serialize("jwt", token, {secure: process.env.NETLIFY_DEV !== "true",httpOnly: true,path: "/",})return jwtCookie
We check process.env.NETLIFY_DEV
because we are not using https
for local development. The helper function we just created can now be used in our signup function:
// 9. Return the user id and a Set-Cookie header with the JWT cookiereturn {statusCode: 200,headers: {"Set-Cookie": createJwtCookie(insertedId, email),"Content-Type": "application/json",},body: JSON.stringify({ id: insertedId, email }),}
The final signup function looks like this:
import bcrypt from "bcryptjs"import { createClient } from "../helpers/db-helper"import { createJwtCookie } from "../helpers/jwt-helper"export async function handler(event) {const dbClient = createClient()let errorStatusCode = 500try {await dbClient.connect()const users = dbClient.usersCollection()const { email, password } = JSON.parse(event.body)const existingUser = await users.findOne({ email })if (existingUser !== null) {errorStatusCode = 409throw new Error(`A user already exists with the email: ${email}`)}const passwordHash = await bcrypt.hash(password, 10)const { insertedId } = await users.insertOne({email,password: passwordHash,})return {statusCode: 200,headers: {"Set-Cookie": createJwtCookie(insertedId, email),"Content-Type": "application/json",},body: JSON.stringify({ id: insertedId, email }),}} catch (err) {return {statusCode: errorStatusCode,body: JSON.stringify({ msg: err.message }),}} finally {dbClient.close()}}
I encourage you test the signup function by manually sending a request with a tool like Postman.
Just fire up your serverless functions with netlify dev
and create a POST request to
localhost:8888/.netlify/functions/signup
with an email and password in the body. Then check your database on MongoDB Atlas afterwards.
Login
With the helper functions we just created, adding the login endpoint will be easy. Here’s all the steps we need to perform in the login function:
// 1. Connect to the database and get a reference to the `users` collection// 2. Get the email and password from the request body// 3. Check to see if the user exists, if not return error (401 Unauthorized)// 4. Compare the password, if it doesn't match return error (401 Unauthorized)// 5. Create a JWT and serialize as a secure http-only cookie// 6. Return the user id and a Set-Cookie header with the JWT cookie
Since the login function is so similar to the signup function, I challenge you to complete the implementation yourself. Otherwise you will find the complete login function by scrolling a little bit.
Here’s some space in case you want to give it a go..
Keep scrolling to get the final implementation…
src/lambda/login.js
import { createClient } from "../helpers/db-helper"import bcrypt from "bcryptjs"import { createJwtCookie } from "../helpers/jwt-helper"export async function handler(event) {const dbClient = createClient()let errorStatusCode = 500try {// 1. Connect to the database and get a reference to the `users` collectionawait dbClient.connect()const users = dbClient.usersCollection()// 2. Get the email and password from the request bodyconst { email, password } = JSON.parse(event.body)// 3. Check to see if the user exists, if not return error (401 Unauthorized)const existingUser = await users.findOne({ email })if (existingUser == null) {errorStatusCode = 401throw new Error(`Invalid password or email`)}// 4. Compare the password, if it doesn't match return error (401 Unauthorized)const matches = await bcrypt.compare(password, existingUser.password)if (!matches) {errorStatusCode = 401throw new Error(`Invalid password or email`)}// 5. Create a JWT and serialize as a secure http-only cookieconst userId = existingUser._idconst jwtCookie = createJwtCookie(userId, email)// 6. Return the user id and a Set-Cookie header with the JWT cookiereturn {statusCode: 200,headers: {"Set-Cookie": jwtCookie,"Content-Type": "application/json",},body: JSON.stringify({ id: userId, email }),}} catch (err) {return {statusCode: errorStatusCode,body: JSON.stringify({ msg: err.message }),}} finally {dbClient.close()}}
I encourage you again to take it for a spin. Use Postman to send a login request to
http://localhost:8888/.netlify/functions/login
Logout
The logout function simply needs to clear the jwt cookie. The way you clear a cookie serverside is by setting the expiry date before the current date. However it’s possible for a browser to be configured to persist expired cookies, therefore we also give it a new value, such as the empty string or “deleted”.
We will add a clearCookie
function to jwt-helper
file:
function clearCookie() {return "jwt=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}export { createJwtCookie, clearCookie }
With this little helper function, the logout function is simply:
import { clearCookie } from "../helpers/jwt-helper"export async function handler() {return {statusCode: 200,headers: {"Set-Cookie": clearCookie(),"Content-Type": "application/json",},body: JSON.stringify({ message: "Logged out successfully" }),}}
A protected endpoint
The last serverless function we will create is a /user
endpoint.
It will demonstrate how we can perform stateless authentication, in other words
checking that the user is authenticated without consulting the database.
To verify the JWT we will need the public key. Create a file at src/lambda/publickey.js and paste your public key similar to this:
export const publicKey = `-----BEGIN PUBLIC KEY-----MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsz/lXRCRReGNWMiTMdKVreP7cbNTHaxn86FAy98jeJPE5Xd+bwhsBt9hzKLQopWWJ3jY4kvb/jlulTZEN4L3zCiS+E18DxTD6H0mEuv1MEtiyOfqOaS0pa+2uwLl6qfdmQOq+JGj2ZeUPCXKHiek25XMB6p5gTpmaY7FaALgEnnHQi/fSNsy773dCKm4I+v7MWZwrRbLD5fQOEOjMGSebWR6P9bSLi4x/nR/kULsXjuI/e1zxfxHL4TcMkcT14WBxxhYqRo4aaWDAEwg3tizTOvm4ZySfXBBfA+ygWVo4DZVLRj3XgoFgwRe2WQrJgjEQmp+Ry1RWXlbsnq2+ZM4A3NZLs8KKRD+YDO2XTnIPO097aMNzPO9jPDCgoecV4toW4nCGj7jc3B4B571XDW8tFWCGXuUyknZ4OVGflXpG6KnNIYgmfqaVIA4YFdiMWiQwCNPfMgU+KSGH1F7S/PlSIV25VvOU4TQ33dl0BWPzpKEXXYbOY8k8bKwqeJTvdNTfuaqXRHdirQDYJOYnyec8eV5CAkZEl9pzeBhd9QbBaFM/ILQijzS+L4G3ALG/HIzaI2Do73X1/q+h1BoIZFnfFU2Ku9yHnhYvYf25fQZTZPP9E96bUjnpVzXYUmfBM0zsIgRPpZTfNsDbhPJkryJQWWb4JGeUb7ZiqKtrBEciQkCAwEAAQ==-----END PUBLIC KEY-----`
Anyone can know your public key, in fact I like to create an endpoint for it. Then I have a way to get the public key, if I create a service somewhere else which needs to verify the JWT. Here’s how you can expose your public key as an endpoint:
export async function handler() {return {statusCode: 200,headers: {"Content-Type": "text/plain",},body: publicKey,}}
Without further ado here’s the code for the /users
serverless function:
import cookie from "cookie"import { publicKey } from "./publickey"import jwt from "jsonwebtoken"export async function handler(event) {const cookies = event.headers.cookie && cookie.parse(event.headers.cookie)if (!cookies || !cookies.jwt) {return {statusCode: 401,body: JSON.stringify({msg: "There is no jwt cookie, so the request is unauthorized",}),}}try {// verify throws an error if it can't verify the jwt.// By default it also checks the exp claim, which is// where our expiry information is.// If the token is successfully verified,// it returns the payload.const payload = jwt.verify(cookies.jwt, publicKey)return {statusCode: 200,headers: {"Content-Type": "application/json",},body: JSON.stringify({ userId: payload.userId, email: payload.email }),}} catch (err) {return {statusCode: 401,body: JSON.stringify({ msg: err.message }),}}}
We now know how to create a protected endpoint. We could add endpoints to upload images, make comments, change the user profile etc. Each one will need to verify the JWT by using the public key. You can also include more “claims” in the payload, specifying more concretely which actions the user can perform.
That was the API. It is by no means a complete authentication system, since we’re missing crucial features like email confirmation and password reset. But you can use this on a simple hobby project, or perhaps you can use it as a base and build features on top of it.
Getting user information locally
One problem we haven’t tackled yet is how the client gets information about the currently logged in user. The JWT contains information about the user, namely the email address and user ID. We can even add the users name, age, bio etc. to the payload, just remember that this payload comes along with each request, so we want to keep it short.
Cool, we’ll just look at the payload in the locally stored JWT… Not so fast! The cookie used to store the JWT is HTTP-only, this means we can’t access it in JavaScript. This is good because it avoids compromising the JWT from XSS (Cross-Site Scripting) attacks.
Our only option then is to send a request to /user
to figure out what user is logged in. That’s a pretty good approach, but I
think we can do better.
We will simply save the user information in local storage when the user logs in or signs up, and then delete it when the user logs out. But what happens when the JWT expires? We will need some way to get a new token, otherwise the client will think its logged in but all requests to protected endpoints will result in 401.
This can be resolved simply by logging out when we receive a 401. There will be an example of this, so keep on reading.
Creating a simple client in React
If you’re new to React, this section might be a bit hard to follow. I assume you know how to use hooks and the context api. Here’s some great resources if you want to read up on these topics before continuing:
How to use React Context effectively (Kent C. Dodds)
I already gave you a sneak peek of how elegant the client side will be once we have our useAuth
hook in place.
Create a file in src/providers/auth-provider.js
with the following content:
import React, { createContext, useContext } from "react"const AuthContext = createContext({})function AuthProvider({ children }) {return (<AuthContext.Provider value={{ user, signup, login, logout }}>{children}</AuthContext.Provider>)}const useAuth = () => useContext(AuthContext)export { useAuth, AuthProvider }
The code doesn’t work yet of course, because we need to define what user
, signup
, login
and logout
are.
We start by creating the context, using the createContext
function from React. Notice that we give it an empty object as default value.
This way when we can still try to destructure the return value from the useAuth
hook:
// user might be undefined, but we can still attempt to destructure the return valueconst { user } = useAuth()
The AuthProvider
is a component which will need to wrap our component tree, in order for the context to be available.
The useAuth
hook we define is actually just a shorthand for useContext(AuthContext)
. It just looks a bit nicer.
The AuthProvider will define three request (signup, login and logout), let us start by creating a helper function. You can define this in the same file, but there’s no need to put it inside the AuthProvider:
async function sendRequest(endpoint, body, successCallback) {const requestOptions = {method: "POST",headers: {Accept: "application/json",},}if (body) {requestOptions.headers["Content-Type"] = "application/json"requestOptions.body = JSON.stringify(body)}const response = await fetch(`/.netlify/functions/${endpoint}`,requestOptions)if (response.ok) {const responseBody = await response.json()successCallback(responseBody)}}
The body is optional, since logout
won’t send a request body. The function also takes a success callback, because we want different things to happen after each request.
With this helper function in place, we can define signup
, login
and logout
as:
function AuthProvider({ children }) {const signup = user => sendRequest("signup", user, saveUser)const login = user => sendRequest("login", user, saveUser)const logout = () => sendRequest("logout", undefined, deleteUser)...}
As mentioned in the previous section, we will save the user in local storage, so the client can get the logged in user immediately on the next visit. Besides saving the user in local storage, we will also keep the user in the AuthProviders state. This way the application will trigger a rerender if the user state changes.
Add the following to the top of the AuthProvider:
const localUserJson = localStorage.getItem("user")const localUser = localUserJson && JSON.parse(localUserJson)const [user, setUser] = useState(localUser)const saveUser = user => {setUser(user)localStorage.setItem("user", JSON.stringify(user))}const deleteUser = () => {setUser(null)localStorage.removeItem("user")}
The default value for the user state will be loaded from local storage. If nothings there, it will simply be undefined. The AuthProvider is now complete.
Open src/index.js and wrap the App component with an AuthProvider:
import { AuthProvider } from "./providers/auth-provider"ReactDOM.render(<AuthProvider><App /></AuthProvider>,document.getElementById("root"))
The only other interesting component is the App component, which toggle between an authenticated app and an unauthenticated app. This idea is borrowed from Kent C. Dodds and was explained earlier in this blog post.
Since this is not a tutorial on basic React, I won’t go into detail with the rest of the components. But you can find the source code for the demo here.
Conclusion
We have built a very simple authentication service with Netlify Functions, and created a React application to demonstrate it.
Our API and frontend is contained in a single repository and can both be started with just one command (ntl dev
), giving us hot reloading and a simulation of the hosted environment on Netlify.
I hope you can see how awesome this workflow is. It’s super simple to deploy our application, just push to GitHub and Netlify will start building.
I am by no means a security expert, and would therefore advise you to be cautious if you’re using this in production. Security is hard to get right and it’s better to outsource it to people who know what they’re doing. That’s why I would go with a service like Netlify Identity, Auth0, Firebase or similar. However, I hope you now have a better understanding of token based authentication, which will help you use and understand third party authentication services.
References
- The authentication code is partially based on Wes Bos’ fullstack React course - https://advancedreact.com
- An introduction to JWT - https://jwt.io/introduction
- Token Based Authentication SPAs. This article gave me the idea of storing the JWT payload in local storage - https://stormpath.com/blog/token-auth-spa
- Original diagram of overall architecture was inspired by this Stackoverflow answer