NextJS SSR - JWT (Access/Refresh Token) Authentication with external Backend
NextJS SSR - JWT (Access/Refresh Token) Authentication with external Backend
Want to build your own cookie based authentication system in NextJS with SSR? In this article we will cover how to do it using access token + refresh token from our external backend!
Let’s get something straight. Putting together an authentication flow by yourself is not trivial by any means. Making it secure and reliable requires experience and patience. Please do not use this code in production. It is merely an intro to authentication systems using JWT tokens.
If you’re building an app that is meant for actual users, please consider using external services like: Auth0, Firebase, Okta, AWS Cognito.
Still reading? That would mean you’re either here to soak up knowledge or just brave enough to roll with your own Authentication System 😎
Let’s get started! ✨
What will we build
Frontend - Next.js SSR with TypeScript Backend - Node.js + Express with TypeScript Database - Postgres + Prisma as ORM
First - I’ll show you a preview of the final app. I didn’t spend much time styling it, I’m sorry but that’s not the point of this blog post 👀.
There will be 3 routes:
/frogs - A screen where the user may admire beautiful frogs. 🐸
/register - A screen where the user can register. After sign-up, the user will be redirected to /
/login - A screen where the user can log in. After sign-in, the user will be redirected to /
That’s all! Looks easy, huh? Believe me when I say that it’s not. Of course, you can use the code that we will be writing in a moment to make new / your own pages.
I just like frogs 🤐
Motivation
A couple of months ago I was looking for the best solution for handling authentication using access token + refresh token. Fortunately enough, I came across this article: The Ultimate Guide to handling JWTs on frontend clients GraphQL by one of my favourite company - Hasura.
I really encourage you to check out their article!
So you probably ask: Why the f… are you even doing this?!
Here’s the answer:
They are showing GraphQL example - I wanted to try Rest API
Imo Hasura’s linked NextJS practical example lacks
Their linked NextJS example also uses GraphQL
Their linked NextJS example is overcomplicated
They don’t show how to connect it to Redux which might get hard
For fun 🤪
What are JWTs 🤔
There are many resources that go into this in more detail than I will.
A JWT token is, at its core, a token with a signature that can be used to verify the source of the token. The contents of the token are typically base64 encoded and although not encrypted, the included signature allows us to easily verify that we created this token. What this means for authentication is: If we can verify a token with one of our secrets, we can assume the contents and the request made using it can be trusted.
Types of JWT Tokens
Access token: short-lived token (in our example it will be around 10 seconds) that let’s user access guarded by content by the signature. When it expires we can “renew” it using refresh token. Gets changed with every “renew” We will store it in client-side memory
Refresh token: long living token (in our example 30 days). Used to renew access token. Gets changed with every “renew” We will store it in server-side memory
Flow
1.User logins/registers with credentials. Server responds with accessToken in the reponse + refreshToken in the cookie (secure and httpOnly)
2.Client is authenticated and does their thing.
3.accessToken gets expired after 10 seconds. The client wants to make a request but it gets rejected with 401 status (unauthorized)
4.We then run a request to refresh our accessToken using our refreshToken. (Silent refresh - later on)
Seems easy peasy huh? Unfortunately, it gets confusing with SSR and Redux.
The problem
This flow is intended to solve problems that come up when using NextJS to authenticate a backend on a different domain than the frontend. An example would be:
1.Rest API hosted on Azure/Aws/Provider that fits your needs
2.NextJS app hosted probably on vercel
As a result, you face one main problem with this setup of deployment and it is all about Cookies The best way to store refresh token on the client according to Hasura’s article would be httpOnly Cookie. But here comes the issue.
IT WON’T WORK ACROSS MULTIPLE DOMAINS
Your frontend is hosted at vercel (appDomain.com)
Your backend is hosted at AWS (‘backendDomain.com)
The client won’t send your session cookie to backendDomain.com and the SSR routes at appDomain.com
So how can we store the cookie on the client side, include our cookie in requests to our SSR routes, and on top of that include our cookie in requests to our backend API?
SIMPLE ANSWER
We will use our Next.js API as a proxy to backendDomain.com! 🤯
I’ve prepared stuff for you…
Imho the best way to learn is doing stuff… So please code along with me. For this purpose I’ve prepared a little backend that contains all the necessary endpoints. Endpoints are explained in the github readme 😎
Feel free to use your own backend, but article will operate on the one that I’ve prepared
Also here’s a starter point to the Next.js app. I’ve prepared some UI components so we can focus strictly on the logic side of stuff. We will be using Styled Components + Tailwindscss + TWG Eslint + TWG Prettier
Finally we’ve set up the starter repo and are ready to go!
Our first task will be to make a NextJS API. It will operate as our proxy between NextJS Client and Node.js Auth Backend.
Basically it extracts headers from the request (Client) and makes the request to the actual backend (Node.js) for us. When response hits us, we update Client headers from the response headers.
I will show you Login and Refresh token cases, simply because other endpoints are pretty analogical.
Other cases are provided in the Github Repo - Step 1. Feel free to just copy them
Login case:
// pages/api/login.ts
import axios from "axios"
import { NextApiRequest, NextApiResponse } from "next"
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { headers, body } = req
try {
const { data, headers: returnedHeaders } = await axios.post(
'http://localhost:3001/auth/login', // Node.js backend path
body, // Login body (email + password)
{ headers } // Headers from the Next.js Client
)
// Update headers on requester using headers from Node.js server response
Object.entries(returnedHeaders).forEach((keyArr) =>
res.setHeader(keyArr[0], keyArr[1] as string)
)
res.send(data) // Send data from Node.js server response
} catch ({ response: { status, data } }) {
// Send status (probably 401) so the axios interceptor can run.
res.status(status).json(data)
}
}
Refresh token case:
// pages/api/refreshToken.ts
import axios from 'axios'
import {NextApiRequest, NextApiResponse} from 'next'
export default async (req: NextApiRequest, res: NextApiResponse) => {
const {headers} = req
try {
const {data, headers: returnedHeaders} = await axios.post(
'http://localhost:3001/auth/refresh-token', // refresh token Node.js server path
undefined,
{
headers,
},
)
// Update headers on requester using headers from Node.js server response
Object.keys(returnedHeaders).forEach(key =>
res.setHeader(key, returnedHeaders[key]),
)
res.status(200).json(data)
} catch (error) {
// we don't want to send status 401 here.
res.send(error)
}
}
Step 2. Custom Axios instance - silent refresh
What happens when our short-living access token expires?
Then our auth guarded requests will fail, and that’s completly fine but… we don’t want our users to manually refresh the page.
We want them to just continue using the page without noticing they were logged out
Basically when we get a response with 401 status (in our case, you can tweak that to your needs), it will try to refresh the token and retry the request using new refreshToken + headers 🔥 MAGIC!
We won’t bother writing our own interceptor but you can definitely do that, it’s not that hard.
But for convenience let’s use the existing library that fits our needs :)
// lib/axios.ts
import axios from 'axios'
import createAuthRefreshInterceptor from 'axios-auth-refresh'
import * as cookie from 'cookie'
import * as setCookie from 'set-cookie-parser'
// Create axios instance.
const axiosInstance = axios.create({
baseURL: 'http://localhost:3000',
withCredentials: true,
})
// Create axios interceptor
createAuthRefreshInterceptor(axiosInstance, failedRequest =>
// 1. First try request fails - refresh the token.
axiosInstance.get('/api/refreshToken').then(resp => {
// 1a. Clear old helper cookie used in 'authorize.ts' higher order function.
if (axiosInstance.defaults.headers.setCookie) {
delete axiosInstance.defaults.headers.setCookie
}
const {accessToken} = resp.data
// 2. Set up new access token
const bearer = `Bearer ${accessToken}`
axiosInstance.defaults.headers.Authorization = bearer
// 3. Set up new refresh token as cookie
const responseCookie = setCookie.parse(resp.headers['set-cookie'])[0] // 3a. We can't just acces it, we need to parse it first.
axiosInstance.defaults.headers.setCookie = resp.headers['set-cookie'] // 3b. Set helper cookie for 'authorize.ts' Higher order Function.
axiosInstance.defaults.headers.cookie = cookie.serialize(
responseCookie.name,
responseCookie.value,
)
// 4. Set up access token of the failed request.
failedRequest.response.config.headers.Authorization = bearer
// 5. Retry the request with new setup!
return Promise.resolve()
}),
)
export default axiosInstance
Step 3. State management system - Redux Toolkit (kinda optional)
I’ve marked this step as kinda optional because you can store the access token anywhere in the memory. It doesn’t have to be Redux specifically
The main reason behind using redux in our app is because I want to show you how to build a fully functional website (many larger websites use Redux) and because of the ability to persist store in the SSR. We will discuss this later on.
We need to store Access Token somewhere in the client memory. I’ve decided to use Redux Toolkit, as the Toolkit gives pleasant experience. If you don’t know what Redux Toolkit is, let me briefly lay it out for you.
It simply is a package that contains helper functions that reduces complexity of stores. Also allows you to write less boilerplate code.
And the most useful feature for me ALLOWS MUTATING THE STATE OUT OF THE BOX
It is a recommended way of handling redux nowadays.
Step 3.1 Setup slices.
A Redux Toolkit slice is a combination of reducer + action creator. It automatically generates action creators and action types.
Let’s init auth slice
// lib/slices/auth.ts
import { createSlice, SerializedError, PayloadAction } from '@reduxjs/toolkit'
export enum AuthStates {
IDLE = 'idle',
LOADING = 'loading',
}
export interface AuthSliceState {
accessToken: string
loading: AuthStates
me?: {
name?: string
email?: string
}
error?: SerializedError
}
// That's what we will store in the auth slice.
const internalInitialState = {
accessToken: '',
loading: AuthStates.IDLE,
me: null,
error: null,
}
// createSlice
export const authSlice = createSlice({
name: 'auth', // name of the slice that we will use.
initialState: internalInitialState,
reducers: {
// here will end up non async basic reducers.
updateAccessToken(state: AuthSliceState, action: PayloadAction<{ token: string }>) {
state.accessToken = action.payload.token
},
reset: () => internalInitialState,
},
extraReducers: (builder) => {} // here will end up async more complex reducers.
})
// Actions generated automatically by createSlice function
export const { updateAccessToken, reset } = authSlice.actions
Now is the time to use the axios instance that we’ve created in Step 2!
Everytime an AsyncThunk requests fails with 401 status it will try to refresh the token and retry the request. For example let’s say you call fetchFrogs function (see below). The flow would be like this:
EXPIRED ACCESS TOKEN CASE
1. Dispatch fetchFrogs() function.
2. Calls our proxy api/frogs
3. Gets rejected cuz access token has been expired => Status 401 (unauthorized)
4. Axios interceptor sees the **401 status**. Fires
5. Tries to refresh the token
6a. Fails => User wasn't logged in the first place or his refresh token has expired too. User needs to login again.
6b. Refresh token succeds.
7. Retry failed call from step 2 7.**PROFIT!**
Fetching frogs 🐸 firstly
1. We need to define function unique name - "auth/frogs"
2. Use our newly created axios instance with interceptor - see Step 2
4. Call the 'api/frogs' endpoint which is our proxy server. (pages/api/frogs)
5. Return the response
5a. Catch the error and reject/ return error for further handling.
When creating AsyncThunk actions, we need to add and handle them manually in the createSlice function. We will use builder callback from extraReducers prop.
A "builder callback" function used to add more reducers, or
an additional object of "case reducers", where the keys should be other
action types
Now comes the time to connect all the slices into store. Let’s create store.ts file.
// lib/store.ts
import {configureStore, combineReducers, Store} from '@reduxjs/toolkit'
import {authSlice} from './slices/auth'
import {frogsSlice} from './slices/frogs'
const combinedReducers = combineReducers({
authReducer: authSlice.reducer,
frogsReducer: frogsSlice.reducer,
})
export const store: Store = configureStore({
reducer: combinedReducers,
})
export type MyThunkDispatch = typeof store.dispatch
Nothing fancy there. Combining the authReducer + frogsReducer, then configuring the store and exporting it. Looks pretty normal huh?
Actaully, there’s a catch here ⚠️ - as u know we will make use of Next.js SSR. So we need store on both:
Server side
and
Client side
Using the code above you will have that of course but… You won’t have the stores in sync. The data on the server won’t be the same as in the client store. That’s a big conflict that we want to avoid. For cases like this, next-redux-wrapper package was created. It automatically creates the store instances for you and makes sure they all have the same state. Also it provides fancy utility wrapper to use with getServerSideProps, getStaticProps and getInitialProps
Lets add that to our above code.
// lib/store.ts
import {configureStore, combineReducers, AnyAction} from '@reduxjs/toolkit'
import {createWrapper, MakeStore, HYDRATE} from 'next-redux-wrapper'
import {authSlice} from './slices/auth'
import {frogsSlice} from './slices/frogs'
// Combine all the slices we created together.
const combinedReducers = combineReducers({
authReducer: authSlice.reducer,
frogsReducer: frogsSlice.reducer,
})
// Type that indicates our whole State will be used for useSelector and other things.
export type OurStore = ReturnType<typeof combinedReducers>
const rootReducer = (
state: ReturnType<typeof combinedReducers>,
action: AnyAction,
) => {
if (action.type === HYDRATE) {
const nextState = {
...state,
...action.payload,
}
return nextState
}
return combinedReducers(state, action)
}
export const store = configureStore<OurStore>({
reducer: rootReducer,
})
const makeStore: MakeStore = () => store
export const wrapper = createWrapper(makeStore, {storeKey: 'key'})
// Type that will be used to type useDispatch() for async actions.
export type MyThunkDispatch = typeof store.dispatch
As you can see, we now have a new function arose: rootReducer. It is a wrapper to our combinedReducers. To manage syncing the stores across server and client we need HYDRATE action.type to be handled.
state contains the old state
payload contains the state at the moment of static generation or server side rendering, so your reducer must merge it with existing client state properly.
const rootReducer = (
state: ReturnType<typeof combinedReducers>,
action: AnyAction,
) => {
if (action.type === HYDRATE) {
const nextState = {
...state,
...action.payload,
}
// If action.type is HYDRATE, then return previous
// server side store state that sits in the action.payload.
return nextState
}
return combinedReducers(state, action)
}
Hydration is a process of population SSR rendered HTML string with JavaScript.
1. HTML string gets rendered in the SSR process
2. JS bundle gets created
3. HTML is sent to client. It improves SEO as search engine bots can read the HTML.
4. JS bundle is sent
5. JavaScript bundle gets hydrated to the HTML string
Then we’ve got MyThunkDispatch TypeScript type which will be used to type our dispatch functions that are using CreateAsyncThunk.
So the last step is to plug the store to the client. To do that we need to simply wrap the whole application with the Provider.
// _app.js
import '../styles/styles.css'
import styled from 'styled-components'
import tw from 'twin.macro'
import {Provider} from 'react-redux'
import {wrapper, store} from '../lib/store'
import {Header} from '../components/Header'
export const PageWrapper = styled.main`
${tw`text-near-black bg-ice-blue w-full relative`}
min-height: 100vh;
`
function MyApp({Component, pageProps}) {
return (
<Provider store={store}>
<PageWrapper>
<Header>
<Component {...pageProps} />
</Header>
</PageWrapper>
</Provider>
)
}
export default wrapper.withRedux(MyApp)
Step 4. Higher order function - authorize
Now it’s the time for the main part of the article, specifically making a higher order function that will wrap all our getServerSideProps In every page that we want to be auth guarded we will need to use this function.
Basically it will have a callback = some function that we will provide. Most likely it will be function that fetches something guarded from our backend. And before calling that callback our authorize function will do numerous things. Like checking if request has accessToken, refreshing the token, overall managing the tokens.
Some time ago we have created wrapper using next-redux-wrapper library in the store.ts file. this wrapper provides us wrapper.getServerSideProps(context => {callback}) function We will use that in a moment to wrap our authorize higher order function.
Let’s start by defining some types:
// lib/authorize.ts
// This type contains context of the wrapper.getServerSideProps + State of our store.
export type ContextWithStore = Omit<
GetServerSidePropsContext & {
store: Store<OurStore, AnyAction>
},
'resolvedUrl'
>
// This type tells us how our callback function will look like.
// We will provide accessToken, store and server response to the callback
// But you can provide whatever you want.
export type Callback = (
accessToken: string,
store: Store<OurStore, AnyAction>,
res: ServerResponse
) => Record<string, unknown> | Promise<Record<string, unknown>>
// General props type for our authorize function.
interface AuthorizeProps {
context: ContextWithStore
callback: Callback
}
From the look of it, you probably have no idea what’s going on. But don’t worry that’s to be expected.
I will guide you step by step with the flow of this crazy function.
1. First we destructure needed variables
const {store, req, res} = context // Store component, Request component, Response component
const {dispatch}: {dispatch: MyThunkDispatch} = store // Async dispatch action.
const {accessToken} = store.getState().authReducer // Get accessToken from memory - redux.
2. Grab refresh token cookie from the client’s brower
if (req) {
// We take cookies (refresh_token) from the client's browser and set it as ours (server-side)
axios.defaults.headers.cookie = req.headers.cookie || null
2a. If the access token acquired in Step 1. exists, then assign it to our custom axios instance
if (accessToken) axios.defaults.headers.Authorization = `Bearer ${accessToken}`
3. Now we split into two paths. - Access Token exists - Access Token doesn’t exist
// We got new set of cookies from the response.
const newAccessToken = response.data.accessToken
// Parse the refreshToken cookie using 'set-cookie-parser' library
const responseCookie = setCookie.parse(response.headers['set-cookie'])[0]
6. Update the set of tokens for the custom axios instance
// Set a fresh cookie header for our axios instance.
axios.defaults.headers.cookie = cookie.serialize(
responseCookie.name,
responseCookie.value,
)
// Set a fresh Authorization header for our axios instance.
axios.defaults.headers.Authorization = `Bearer ${newAccessToken}`
7. Update cookies in the client’s browser + update redux store
// Update the client's refresh token
res.setHeader('set-cookie', response.headers['set-cookie'])
// And last step => update redux store accessToken
dispatch(updateAccessToken({token: newAccessToken}))
8. Handle errors in try-catch block
We end up here when we fail in refreshing the token. So basically our refresh token has expired or is invalid. We’re UNAUTHORIZED!
try {
...
} catch (error) {
// Handle error case. The most possible error would be
// axios.get('/api/refreshToken) failing
// that would mean our refreshToken has expired or
// it is simply wrong. So let's reset our auth slice.
// So we get logged out :)
// Reset the store!
store.dispatch(reset())
// And return nothing :)
return null
}
9. Now that we have access token, we can finally call our callback.
It doesn’t mean we’re authorized yet. Our access token might be expired or invalid
// We call our callback with provided props: Access Token, Store component, Response Component, we will only use the `store` prop. You can pass what's fiting your needs.
const cbResponse = await callback(accessToken, store, res)
11. Handle a case where our callback failed and axios interceptor runned
If our callback has failed for the first time, then most probably it has run our axios interceptor to refresh the token. So because of that, the set of tokens that we have are invalid. We need to update them from the callback response.
if (axios.defaults.headers.setCookie) {
// If callback fired refreshing the token
// then the interceptor set a helper header (see axios.ts file)
// that we will use to update the client's refreshToken.
res.setHeader('set-cookie', axios.defaults.headers.setCookie)
// We also update the accessToken
dispatch(
updateAccessToken({
token: axios.defaults.headers.Authorization.split(' ')[1],
}),
)
// Then we clean up the header.
delete axios.defaults.headers.setCookie
}
12. Return the response from callback
return cbResponse
13. Handle errors in try-catch block
We end up here when callback fn has failed + axios interceptor has failed to refresh the token. So basically we’re UNAUTHORIZED
try {
// ...
} catch (e) {
// We're here when axios interceptor fails to refresh the token.
// Here we should handle indicating that the user is not authorized or logging him out.
// We will simply reset the store.
store.dispatch(reset())
return null
}
Now we could use our function like this
That’s how you can use this function for now. But that’s not what we’re aiming for tho.
We will create a wrapper for wrapper.getServerSideProps(...)
We will create a wrapper for wrapper.getServerSideProps(...) that will automatically dispatch the fetchUser() action for us. So we’re not writing it manually on every page.
// lib/authorize.ts
interface UserProps {
callback: Callback;
}
export const user = ({callback}: UserProps) =>
// 1. We use wrapper from next-wrapper-redux library to wrap our gerServerSideProps
// with our redux store.
// property "context" contains store
wrapper.getServerSideProps(async (context: ContextWithStore) => {
const {dispatch}: {dispatch: MyThunkDispatch} = context.store
// 2. Call our authorize Higher order Function
return authorize({
context,
callback: async (...props) => {
// 3. If we currently don't have our user fetched
// Then we're not authorized.
// So try to fetch the user.
if (!context.store.getState().authReducer.me)
await dispatch(fetchUser())
// 4. return the response from the callback
return callback(...props)
},
})
})
We will now move on to writing a component that will check if user is authenticated In our case it will check if it has “me” object in our redux store. But you may implement your own logic to check if user is already authorized
That’s only an additional check on the front-end. We are checking cookies etc. on the backend + SSR so don’t worry.
If you want to have proper admin panel you should consider splitting it into two separate apps => client and admin panel
// components/AuthGuard.tsx
import React from 'react'
import { useSelector } from 'react-redux'
import { OurStore } from '../lib/store'
// You might want to implement some sort of role system. I will not cover that.
type Props = {
readonly role?: 'admin'
readonly customText?: React.ReactNode
}
export const AuthGuard: React.FC<Props> = ({ children, role, customText }) => {
// Get `me` object from client side redux store.
const { loading, me } = useSelector((state: OurStore) => state.authReducer)
// Loading indicator
if (loading === 'loading') {
return <>loading...</>
}
// Without role allow all authorized users
if (me) {
return <>{children}</>
}
if (role === 'admin' && me?.role === 'ADMIN') {
return <>{children}</>
}
// This happens if user is unauthorized :)
return (
<section>
<h2 className="text-center">Unauthorized</h2>
<div className="text-center">
{customText ||
"You don't have permission to access this page. Pleae contact an admin if you think something is wrong."}
</div>
</section>
)
}
A really simple component ☺️
Let’s use it in our index.tsx page
// pages/index.tsx
import React from 'react'
import dynamic from 'next/dynamic'
import Image from 'next/image'
import {useRouter} from 'next/dist/client/router'
import {fetchFrogs} from '../lib/slices/frogs'
import {MyThunkDispatch} from '../lib/store'
import {user} from '../lib/authorize'
// Dynamicaly import the AuthGuard component.
const AuthGuard = dynamic<{readonly customText: React.ReactNode}>(() =>
import('../components/AuthGuard').then(mod => mod.AuthGuard),
)
type Frog = {id: string; webformatURL: string}
export const Home = ({frogs}: {frogs: Frog[]}) => {
const router = useRouter()
return (
<AuthGuard
// Our custom message to unauthorized users.
customText={
<p className="text-72 mb-24">
<span
className="text-primary underline cursor-pointer"
onClick={() => router.push('/login')}>
Login
</span>
to pet the phrog 👀
</p>
}>
<div className="flex flex-col items-center">
<p className="text-72 mb-24 text-center">You may pet the phrog 🐸</p>
{frogs?.map((frog, index) => (
<div key={index} className="mb-4">
<Image
src={frog.webformatURL}
width={700}
height={500}
className="rounded-xl"
/>
</div>
))}
</div>
</AuthGuard>
)
}
export const getServerSideProps = user({
callback: async (_, store) => {
const {dispatch}: {dispatch: MyThunkDispatch} = store
await dispatch(fetchFrogs())
return {
props: {
frogs: store.getState().frogsReducer.frogs,
},
}
},
})
export default Home
As you can see we have used dynamic import. Have a read about it here:
Step 6. Connect all the building blocks that we have created
So… this will be the last step of our guide.
I bet you’re tired after reading this amount of code but hang in there.
Now the most pleasant step - connecting all the dots.
- First lets add Login action to pages/login.tsx
// pages/login.tsx
import {useFormik} from 'formik'
import React from 'react'
import * as yup from 'yup'
import {useRouter} from 'next/dist/client/router'
import styled from 'styled-components'
import tw from 'twin.macro'
import {useDispatch} from 'react-redux'
import InputWithError from '../components/InputWithError'
import FormWithLabel from '../components/FormWithLabel'
import Logo from '../components/Logo'
import {MyThunkDispatch} from '../lib/store'
import {login} from '../lib/slices/auth'
// ...Component
const dispatch: MyThunkDispatch = useDispatch()
const formik = useFormik({
validationSchema: loginSchema,
initialValues,
onSubmit: async values => {
await dispatch(login(values))
router.push('/')
},
})
You could do some fancy stuff, like if user is already logged in, then don’t allow him to acces login route, but I won’t focus on that in this guide - that would be just client side hacking.
You could wrap it inside AuthGuard and add custom callback that redirects when user is authorized
Oops! Something went wrong while submitting the form.
By clicking “Accept all”, you agree to the storing of cookies on your device to enhance site navigation, analyze site usage, and assist in our marketing efforts. View our Privacy Policy for more information.