Skip to content

Spam bots and honey pots

Our application is now protected against Cross-Site Request Forgery (CSRF) attacks. ๐ŸŽ‰๐ŸŽ‰

In this step, we will add an extra layer of security to protect our application from spam bots.

To protect against spam bots, we can hide a secret โ€˜honeypotโ€™ field in our forms.

A โ€˜honeypotโ€™ is a hidden field in a form that is invisible to users but visible to spam bots. When a spam bot fills out the form, it will fill in the โ€˜honeypotโ€™ field as well. We can then check if the โ€˜honeypotโ€™ field is filled in and reject the form submission if it is.

Spam bot honeypot attack

Letโ€™s see how this works.

Our application is already set up to use a honeypot field. It enters our application through the data object returned from the loader function we import from app/__root.server.tsx:

app/root.tsx
export { headers, loader } from './__root.server.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">
20 collapsed lines
<HeaderWithSearch />
<div className="flex-1">
<Outlet />
</div>
<div className="container flex justify-between pb-5">
<ThemeSwitch userPreference={data?.requestInfo.userPrefs.theme} />
</div>
<FooterMenuRight />
</div>
<EpicToaster
closeButton
position="bottom-right"
theme={theme}
expand
richColors
duration={5000}
/>
</Document>
</ParallaxProvider>
</AuthenticityTokenProvider>
)
}

With this in place, we can add HomeypotInputs to any form in our application that we want to protect.

One of these is the login form, which is located in app/routes/_auth+/login.tsx, and is already protected:

app/routes/_auth+/login.tsx
6 collapsed lines
import { 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 { AuthenticityTokenInput } from 'remix-utils/csrf/react'
import { CSRFError } from 'remix-utils/csrf/server'
import { HoneypotInputs } from 'remix-utils/honeypot/react'
import { z } from 'zod'
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
91 collapsed lines
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 { redirectWithToast } from '#app/utils/toast.server.ts'
import { PasswordSchema, UsernameSchema } from '#app/utils/user-validation.ts'
import { type Route } from './+types/login.ts'
import { handleNewSession } from './login.server.ts'
import { csrf } from '~/utils/csrf.server'
export const handle: SEOHandle = {
getSitemapEntries: () => null,
}
const LoginFormSchema = z.object({
username: UsernameSchema,
password: PasswordSchema,
redirectTo: z.string().optional(),
remember: z.boolean().optional(),
})
const AuthenticationOptionsSchema = z.object({
options: z.object({ challenge: z.string() }),
}) satisfies z.ZodType<{ options: PublicKeyCredentialRequestOptionsJSON }>
export async function loader({ request }: Route.LoaderArgs) {
await requireAnonymous(request)
return {}
}
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, {
schema: (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.value
return handleNewSession({
request,
session,
remember: remember ?? false,
redirectTo,
})
}
export default function LoginPage({ actionData }: Route.ComponentProps) {
const 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">
9 collapsed lines
<div className="mx-auto w-full max-w-md">
<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 />
91 collapsed lines
<Field
labelProps={{ children: 'Username' }}
inputProps={{
...getInputProps(fields.username, { type: 'text' }),
autoFocus: true,
className: 'lowercase',
autoComplete: 'username',
}}
errors={fields.username.errors}
/>
<Field
labelProps={{ children: 'Password' }}
inputProps={{
...getInputProps(fields.password, {
type: 'password',
}),
autoComplete: 'current-password',
}}
errors={fields.password.errors}
/>
<div className="flex justify-between">
<CheckboxField
labelProps={{
htmlFor: fields.remember.id,
children: 'Remember me',
}}
buttonProps={getInputProps(fields.remember, {
type: 'checkbox',
})}
errors={fields.remember.errors}
/>
<div>
<Link
to="/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">
<StatusButton
className="w-full"
status={isPending ? 'pending' : (form.status ?? 'idle')}
type="submit"
disabled={isPending}
>
Log in
</StatusButton>
</div>
</Form>
<hr className="my-4" />
<div className="flex flex-col gap-5">
<PasskeyLogin
redirectTo={redirectTo}
remember={fields.remember.value === 'on'}
/>
</div>
<hr className="my-4" />
<ul className="flex flex-col gap-5">
{providerNames.map((providerName) => (
<li key={providerName}>
<ProviderConnectionForm
type="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>
<Link
to={
redirectTo
? `/signup?redirectTo=${encodeURIComponent(redirectTo)}`
: '/signup'
}
>
Create an account
</Link>
</div>
</div>
</div>
</div>
</div>
)
}
96 collapsed lines
const VerificationResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
location: z.string(),
}),
z.object({
status: z.literal('error'),
error: z.string(),
}),
])
function PasskeyLogin({
redirectTo,
remember,
}: {
redirectTo: string | null
remember: boolean
}) {
const [isPending] = useTransition()
const [error, setError] = useState<string | null>(null)
const [passkeyMessage, setPasskeyMessage] = useOptimistic<string | null>(
'Login with a passkey',
)
const navigate = useNavigate()
async function handlePasskeyLogin() {
try {
setPasskeyMessage('Generating Authentication Options')
// Get authentication options from the server
const optionsResponse = await fetch('/webauthn/authentication')
const json = await optionsResponse.json()
const { options } = AuthenticationOptionsSchema.parse(json)
setPasskeyMessage('Requesting your authorization')
const authResponse = await startAuthentication({ optionsJSON: options })
setPasskeyMessage('Verifying your passkey')
// Verify the authentication with the server
const verificationResponse = await fetch('/webauthn/authentication', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authResponse, remember, redirectTo }),
})
const verificationJson = await verificationResponse.json().catch(() => ({
status: 'error',
error: 'Unknown error',
}))
const parsedResult =
VerificationResponseSchema.safeParse(verificationJson)
if (!parsedResult.success) {
throw new Error(parsedResult.error.message)
} else if (parsedResult.data.status === 'error') {
throw new Error(parsedResult.data.error)
}
const { location } = parsedResult.data
setPasskeyMessage("You're logged in! Navigating...")
await navigate(location ?? '/')
} catch (e) {
const errorMessage = getErrorMessage(e)
setError(`Failed to authenticate with passkey: ${errorMessage}`)
}
}
return (
<form action={handlePasskeyLogin}>
<AuthenticityTokenInput />
<StatusButton
id="passkey-login-button"
aria-describedby="passkey-login-button-error"
className="w-full"
status={isPending ? 'pending' : error ? 'error' : 'idle'}
type="submit"
disabled={isPending}
>
<span className="inline-flex items-center gap-1.5">
<Icon name="passkey" />
<span>{passkeyMessage}</span>
</span>
</StatusButton>
<div className="mt-2">
<ErrorList errors={[error]} id="passkey-login-button-error" />
</div>
</form>
)
}
export const meta: Route.MetaFunction = () => {
return [{ title: 'Login to Epic News' }]
}
export function ErrorBoundary() {
return <GeneralErrorBoundary />
}

We can see the honeypot in action if we visit the http://localhost:3000/login page and inspect the form with our browserโ€™s developer tools:

Honeypot field

Can you see the hidden fields in your own browser? ๐Ÿ•ต๏ธโ€โ™‚๏ธ

Well, spam bots canโ€™t! If they fill in this field, our application will know its not a genuine user, and reject their submission. ๐Ÿšซ๐Ÿค–

As with CSRF protection, adding a honeypot to our application is straightforward in Remix.

Letโ€™s update your assignment to document this new security feature. ๐Ÿš€

In the next step, we will create a new user account on our local site using the signup form weโ€™ve just secured. ๐ŸŽ‰