Cross Site Request Forgery (CSRF)
Introduction
Section titled βIntroductionβNow that we have routing set up, we need a way to secure our application.
We will start by adding cross-site request forgery CSRF protection to our application.
How can we protect against CSRF?
Section titled βHow can we protect against CSRF?βOne way to protect against CSRF attacks is to use a CSRF token.
This is a unique string of random characters that the app generates for each user session. Every time a user makes a request to the server, the token is sent along with it.
Before processing any request, the server checks that the token is valid and that it matches the userβs session. If it doesnβt, the request is rejected.
Letβs add this now.
Add CSRF protection
Section titled βAdd CSRF protectionβLuckily, adding CSRF protection to our application is relatively easy.
-
Open
app/root.tsx. -
Add the following imports to the top of your
app/root.tsxfile:app/root.tsx import { Outlet, useLoaderData } from 'react-router'import { ParallaxProvider } from 'react-scroll-parallax'import { type Route } from './+types/root.ts'import { type loader } from './__root.server.tsx'import { GeneralErrorBoundary } from './components/error-boundary.tsx'import FooterMenuRight from './components/organisms/Footer/FooterMenuRight'import HeaderWithSearch from './components/organisms/HeaderWithSearch'import Document from './components/shared-layout/Document.tsx'import { ThemeSwitch, useTheme } from './routes/resources+/theme-switch.tsx'import { useNonce } from './utils/nonce-provider.ts'import rootLinkElements from './utils/providers/rootLinkElements.ts'import { AuthenticityTokenProvider } from 'remix-utils/csrf/react'import { EpicToaster } from './components/ui/sonner.tsx'import { useToast } from './components/toaster.tsx' -
Wrap all the JSX returned by the
Appcomponent with theAuthenticityTokenProvider, then pass acsrfTokenfrom thedataobject to it:app/root.tsx export default function App() {const data = useLoaderData<typeof loader | null>()const nonce = useNonce()const theme = useTheme()return (<AuthenticityTokenProvider token={data?.csrfToken ?? ''}><ParallaxProvider><Document theme={theme} nonce={nonce} honeyProps={data?.honeyProps}><div className="flex h-screen flex-col justify-between"><HeaderWithSearch /><div className="flex-1"><Outlet /></div><div className="container flex justify-between pb-5"><ThemeSwitch userPreference={data?.requestInfo.userPrefs.theme} /></div><FooterMenuRight /></div></Document></ParallaxProvider></AuthenticityTokenProvider>)} -
Next, add a new component called
EpicToastercarefully in the space shown below. Add it just above the closing</Document>tag:app/root.tsx export default function App() {const data = useLoaderData<typeof loader | null>()const nonce = useNonce()const theme = useTheme()useToast(data?.toast)return (<AuthenticityTokenProvider token={data?.csrfToken ?? ''}><ParallaxProvider><Document theme={theme} nonce={nonce} honeyProps={data?.honeyProps}><div className="flex h-screen flex-col justify-between"><HeaderWithSearch /><div className="flex-1"><Outlet /></div><div className="container flex justify-between pb-5"><ThemeSwitch userPreference={data?.requestInfo.userPrefs.theme} /></div><FooterMenuRight /></div><EpicToastercloseButtonposition="bottom-right"theme={theme}expandrichColorsduration={5000}/></Document></ParallaxProvider></AuthenticityTokenProvider>)} -
Save your changes.
-
Remember to organise the imports at the top of
app/root.tsxby using the VS Code keyboard shortcut SHIFT + ALT + O (Windows) or SHIFT + OPT + O (Mac) -
Letβs check for a CSRF token when someone tries to login to the website. If they try to login without a valid CSRF token, we will redirect them to the home page and display a toast notification to inform the user.
Open the file
app/routes/_auth+/login.tsx. -
Add the following imports to the top of the file:
app/routes/_auth+/login.tsx 19 collapsed linesimport { getFormProps, getInputProps, useForm } from '@conform-to/react'import { getZodConstraint, parseWithZod } from '@conform-to/zod'import { type SEOHandle } from '@nasa-gcn/remix-seo'import { startAuthentication } from '@simplewebauthn/browser'import { useOptimistic, useState, useTransition } from 'react'import { data, Form, Link, useNavigate, useSearchParams } from 'react-router'import { HoneypotInputs } from 'remix-utils/honeypot/react'import { z } from 'zod'import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'import { Spacer } from '#app/components/spacer.tsx'import { Icon } from '#app/components/ui/icon.tsx'import { StatusButton } from '#app/components/ui/status-button.tsx'import { login, requireAnonymous } from '#app/utils/auth.server.ts'import {ProviderConnectionForm,providerNames,} from '#app/utils/connections.tsx'import { checkHoneypot } from '#app/utils/honeypot.server.ts'import { getErrorMessage, useIsPending } from '#app/utils/misc.tsx'import { PasswordSchema, UsernameSchema } from '#app/utils/user-validation.ts'import { type Route } from './+types/login.ts'import { handleNewSession } from './login.server.ts'import { AuthenticityTokenInput } from 'remix-utils/csrf/react'import { CSRFError } from 'remix-utils/csrf/server'import { csrf } from '~/utils/csrf.server'import { redirectWithToast } from '#app/utils/toast.server.ts' -
Next, find the
actionfunction around line 52 in the same file.Add the following block of code just after its opening bracket:
app/routes/_auth+/login.tsx export async function action({ request }: Route.ActionArgs) {try {await csrf.validate(request)} catch (error) {if (error instanceof CSRFError) {return redirectWithToast(`/`, {description: 'CSRF token is invalid. Please try again in a new window',type: 'error',})}return redirectWithToast(`/`, {description: 'Something went wrong. Please try again in a new window',type: 'error',})}await requireAnonymous(request)const formData = await request.formData()await checkHoneypot(formData)const submission = await parseWithZod(formData, {33 collapsed linesschema: (intent) =>LoginFormSchema.transform(async (data, ctx) => {if (intent !== null) return { ...data, session: null }const session = await login(data)if (!session) {ctx.addIssue({code: z.ZodIssueCode.custom,message: 'Invalid username or password',})return z.NEVER}return { ...data, session }}),async: true,})if (submission.status !== 'success' || !submission.value.session) {return data({ result: submission.reply({ hideFields: ['password'] }) },{ status: submission.status === 'error' ? 400 : 200 },)}const { session, remember, redirectTo } = submission.valuereturn handleNewSession({request,session,remember: remember ?? false,redirectTo,})} -
Find the
LoginPagecomponent around line 104 in the same file. Look carefully for theFormelement in the JSX being returned from this function, and add in theAuthenticityTokenInputcomponent just below theHoneypotInputscomponent:app/routes/_auth+/login.tsx export default function LoginPage({ actionData }: Route.ComponentProps) {14 collapsed linesconst isPending = useIsPending()const [searchParams] = useSearchParams()const redirectTo = searchParams.get('redirectTo')const [form, fields] = useForm({id: 'login-form',constraint: getZodConstraint(LoginFormSchema),defaultValue: { redirectTo },lastResult: actionData?.result,onValidate({ formData }) {return parseWithZod(formData, { schema: LoginFormSchema })},shouldRevalidate: 'onBlur',})return (<div className="flex min-h-full flex-col justify-center pt-20 pb-32"><div className="mx-auto w-full max-w-md">8 collapsed lines<div className="flex flex-col gap-3 text-center"><h1 className="text-h1">Welcome back!</h1><p className="text-body-md text-muted-foreground">Please enter your details.</p></div><Spacer size="xs" /><div><div className="mx-auto w-full max-w-md px-8"><Form method="POST" {...getFormProps(form)}><AuthenticityTokenInput /><HoneypotInputs /><FieldlabelProps={{ children: 'Username' }}inputProps={{...getInputProps(fields.username, { type: 'text' }),autoFocus: true,className: 'lowercase',autoComplete: 'username',}}errors={fields.username.errors}/>48 collapsed lines<FieldlabelProps={{ children: 'Password' }}inputProps={{...getInputProps(fields.password, {type: 'password',}),autoComplete: 'current-password',}}errors={fields.password.errors}/><div className="flex justify-between"><CheckboxFieldlabelProps={{htmlFor: fields.remember.id,children: 'Remember me',}}buttonProps={getInputProps(fields.remember, {type: 'checkbox',})}errors={fields.remember.errors}/><div><Linkto="/forgot-password"className="text-body-xs font-semibold">Forgot password?</Link></div></div><input{...getInputProps(fields.redirectTo, { type: 'hidden' })}/><ErrorList errors={form.errors} id={form.errorId} /><div className="flex items-center justify-between gap-6 pt-3"><StatusButtonclassName="w-full"status={isPending ? 'pending' : (form.status ?? 'idle')}type="submit"disabled={isPending}>Log in</StatusButton></div></Form>31 collapsed lines<hr className="my-4" /><div className="flex flex-col gap-5"><PasskeyLoginredirectTo={redirectTo}remember={fields.remember.value === 'on'}/></div><hr className="my-4" /><ul className="flex flex-col gap-5">{providerNames.map((providerName) => (<li key={providerName}><ProviderConnectionFormtype="Login"providerName={providerName}redirectTo={redirectTo}/></li>))}</ul><div className="flex items-center justify-center gap-2 pt-6"><span className="text-muted-foreground">New here?</span><Linkto={redirectTo? `/signup?redirectTo=${encodeURIComponent(redirectTo)}`: '/signup'}>Create an account</Link></div></div></div></div></div>)} -
Open
app/root.tsx. -
We will deliberately add an invalid CSRF token to the
AuthenticityTokenProviderto test our new code.Replace the
tokenprop with the following:app/root.tsx export default function App() {const data = useLoaderData<typeof loader | null>()const nonce = useNonce()const theme = useTheme()useToast(data?.toast)return (<AuthenticityTokenProvider token={'invalid'}>25 collapsed lines<ParallaxProvider><Document theme={theme} nonce={nonce} honeyProps={data?.honeyProps}><div className="flex h-screen flex-col justify-between"><HeaderWithSearch /><div className="flex-1"><Outlet /></div><div className="container flex justify-between pb-5"><ThemeSwitch userPreference={data?.requestInfo.userPrefs.theme} /></div><FooterMenuRight /></div><EpicToastercloseButtonposition="bottom-right"theme={theme}expandrichColorsduration={5000}/></Document></ParallaxProvider></AuthenticityTokenProvider>)} -
Next, in your browser, click on the Login button in the top right corner of the screen.
Login with the following details:
Username: kody Password: kodylovesyou
-
When you click βLoginβ, you should be redirected to the home page and see a toast notification informing you that the CSRF token is invalid:

Summary
Section titled βSummaryβWeβve added a strong security feature to our application that is crucial for you to write about in your assignment.
Next steps
Section titled βNext stepsβIn the next step, we will secure our application against spambots by adding a βhoneypotβ field to our forms.