The Backend
In the previous page where we’ve implemented the sign in logic, we called two API endpoints, /api/nonce
and /api/verify
. We’ll implement both of the APIS to complete the feature.
The general approach from a backend perspective is that:
- Users will request for a nonce to initiate their sign in process
- After signing the nonce, user calls
verify
to verify the signature against the previously requested nonce - If the nonce is valid, we create a JWT token for the user
- User will use the JWT token to authenticate themselves for any protected API
Nonce
The nonce
is a security measure to make sure that a signature cannot be stolen to perform a replay attack. Without a nonce
, malicious actors may steal a user’s signature and pretend to own the address that is signing in. Let’s implement the /api/nonce
API with NextJS’s serverless functions. Create a new file for the API at src/pages/api/nonce.ts
(We use NextJS pages for our APIs, feel free to use the app router at src/app/api/...
if you like)
Remember to configure your JWT_SECRET
// src/pages/api/nonce.ts
import type { NextApiRequest, NextApiResponse } from "next"
import crypto from "crypto"
type Data = {
nonce: string,
}
export default function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
// 1. create a random string, you may use other approach if you like
const nonce = crypto.randomUUID()
// 2. tie the nonce to user's session as cookie so it can be used for verification later
/** This is just an example. In production, you should encrypt your cookies. */
res.setHeader("Set-Cookie", `siws-nonce=${nonce}; Path=/; HttpOnly; Secure; SameSite=Strict`)
res.status(200).json({ nonce })
}
Verify
Almost there! Now let’s create the verify
API. Create a new file at src/pages/api/verify.ts
import type { NextApiRequest, NextApiResponse } from "next"
import jwt from "jsonwebtoken"
import { verifySIWS } from "@talismn/siws"
type Data = {
error?: string
jwtToken?: string
}
export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
try {
// make sure the session is valid and has a nonce from previous request
const nonce = req.cookies["siws-nonce"]
if (!nonce) return res.status(401).json({ error: " Invalid session! Please try again." })
// get the key params from the request body
const { signature, message, address } = JSON.parse(req.body)
// verify that signature is valid
const siwsMessage = await verifySIWS(message, signature, address)
// validate that nonce is correct to prevent replay attack
if (nonce !== siwsMessage.nonce)
res.status(401).json({ error: "Invalid nonce! Please try again." })
// only accept SIWS requests where the domain is what you allow to prevent phishing attack!
// validate that domain is correct to prevent phishing attack
if (siwsMessage.domain !== 'localhost')
throw new Error("SIWS Error: Signature was meant for different domain.")
// ... add additional validation as necessary
// now that user has proved their ownership to the signing address
// we can create a JWT token that allows users to authenticate themselves
// so they don't have to sign in again
const jwtPayload = {
address: siwsMessage.address,
// ... typically you will also query the user's id from your database and encode it in the payload
}
// sign the JWT token. Remember to keep your JWT secret securely.
const jwtToken = jwt.sign(jwtPayload, "JWT_SECRET", {
algorithm: "HS256",
})
// to securely store the JWT token, you should set it as an httpOnly cookie
// we're returning this to store client side for demonstration purposes only
res.status(200).json({ jwtToken })
} catch (e: any) {
res.status(401).json({ error: e.message ?? "Invalid signature!" })
}
}
Protected
So we’ve got all the pieces to complete Sign-In with Substrate! Let’s demonstrate how we could protect our data with all the things we’ve built so far. Let’s create src/pages/api/protected.ts
:
import type { NextApiRequest, NextApiResponse } from "next"
import jwt from "jsonwebtoken"
import crypto from "crypto"
type Data = {
randomText?: string
error?: string
}
export default function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
const authorisationHeader = req.headers["authorisation"]
if (typeof authorisationHeader !== "string")
return res.status(401).json({ error: "You are not logged in!" })
const jwtToken = authorisationHeader.split(" ")[1]
try {
// verify that JWT is correct
jwt.verify(jwtToken, "JWT_SECRET")
// ... typically you would encode a user's ID in the JWT token
// then decode the JWT to get the user's ID so you can query data for that user ID
res.status(200).json({ randomText: crypto.randomBytes(8).toString("hex") })
} catch (e) {
res.status(401).json({ error: "You are not logged in!" })
}
}