Wraps Payload access control in a TOTP verification step so users must enter a code from an authenticator app after login. It is configured through the plugins array for a single auth-enabled collection and needs a Next.js proxy or middleware file that passes the pathname as a header. Requires Payload 3.88 or later.
Add an extra security layer to PayloadCMS using a Time-based One-time Password (TOTP).
Installation
npm i payload-totp163
Stars
22
Forks
1
Open Issues
2 weeks ago
Last Updated
0
Weekly Downloads
v3.0.6
npm Version
373.0 KB
Unpacked Size
3
Dependencies
Trends appear after 7 days of data. Daily snapshots have not started yet.
Trends appear after 7 days of data. Daily snapshots have not started yet.
Each point is npm's rolling 7-day download count.
Health Score
Composite score based on GitHub activity, stars, npm downloads, publish recency, dependency count, and package size.
Badge for your README
[](https://payloaddirectory.dev/plugins/821717323-root)<a href="https://payloaddirectory.dev/plugins/821717323-root"><img src="https://payloaddirectory.dev/badge/821717323-root.svg" alt="Payload Directory"></a>README
If you find this plugin useful, consider supporting its development through donations. Your contributions help improve security and stability!
TOTP is a widely used authentication mechanism that generates a unique, time-sensitive code based on a shared secret and the current time. This code is typically used as a second factor in authentication (2FA), adding an extra layer of security to protect against unauthorized access.
This plugin enhances security by wrapping the existing access controls under a TOTP verification process. Users must enter a valid TOTP code generated by an authenticator app (such as Google Authenticator, Authy, or Microsoft Authenticator) to gain access, reducing the risk of unauthorized logins even if credentials are compromised.
Install the plugin using any JavaScript package manager like pnpm, npm, or Yarn:
pnpm add payload-totp
In the plugins array of your Payload Config, call the plugin with options:
import { buildConfig } from 'payload'
import { payloadTotp } from 'payload-totp'
const config = buildConfig({
collections: [
{
slug: 'users',
auth: true,
fields: [],
},
],
plugins: [
payloadTotp({
collection: 'users',
// see below for a list of available options
}),
],
})
export default config
IMPORTANT: The plugin overrides all collections, therefore it should be the last plugin in the array, or at least not followed by plugins that add collections/globals. Furthermore, the plugin can break the default behavior for non–user-based access. Please read the Access Wrapper section.
Now, you need to modify the proxy.ts or create it if doesn't exist. The proxy will pass the pathname as a header. This is necessary because we don't have access to the pathname in the server-side component. Without this part, it will create an endless redirect loop.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
const response = NextResponse.next()
response.headers.append('x-pathname', pathname)
return response
}
Next.js 15: the proxy.ts convention was introduced in Next.js 16. On Next.js 15, use the same code in middleware.ts and name the exported function middleware instead.
collectionThe collection property specifies which collection with auth enabled should have TOTP protection. Currently, the plugin supports TOTP for a single collection at a time.
disabledAllows you to conditionally disable the plugin based on runtime conditions.
payloadTotp({
collection: 'users',
disabled: process.env.NODE_ENV === 'development',
})
When disabled, none of the plugin's behaviour is registered: your own access functions are left alone, the TOTP strategy, the admin provider, the setup and verify views and the endpoints are not added, and the account page shows no authenticator field.
The totpSecret field stays on the collection, and the totp-attempts
collection of the brute-force protection stays
registered, so toggling the option doesn't change the database schema and users
who had already enrolled keep their secret for whenever the plugin is enabled
again.
forceSetupBy default, the plugin does not force users to configure TOTP. The TOTP verification will only be prompted if the user has configured it. This option forces all users to configure their TOTP after login, enhancing security by ensuring 2FA is enabled for all accounts.
The access wrapper enforces it as well, not only the admin panel. Until a user has set TOTP up, every operation the access wrapper covers is refused, except reading their own document in the TOTP collection, which /api/<collection>/me needs; setting TOTP up goes through the plugin's own endpoint. This holds for REST, GraphQL and the Local API with overrideAccess: false, and for totpAccess applied by hand. API keys, and users of other auth collections (who can't set TOTP up), are not affected. A user counts as enrolled through the hasTotp field read with their document, so a custom auth strategy that builds the user without it is treated as not enrolled.
Access left unwrapped (disableAccessWrapper, custom.totp.disableAccessWrapper) and custom endpoints are not covered. Wrap them with totpAccess; on the TOTP collection's read, pass allowOwnDocumentDuringSetup so that me keeps working:
read: totpAccess(readAccess, { allowOwnDocumentDuringSetup: true }),
disableAccessWrapperThe disableAccessWrapper property disables the default access wrapper for all collections and globals. Read more about it.
totpThe totp property is used to configure the TOTP class from the otpauth package. You can customize the following options:
algorithm: The hash algorithm to use (e.g. 'SHA1', 'SHA256', 'SHA512')digits: Number of digits in the generated token (default: 6)issuer: The issuer name to display in authenticator appsperiod: Token validity period in seconds (default: 30)forceWhiteBackgroundOnQrCodeWhen enabled, the QR code shown on the setup page is rendered with a white background to improve readability when using a dark theme. Disabled by default.
By default, PayloadCMS has access set to ({user}) => Boolean(user). Since PayloadCMS naturally handles access for logged-in users, this plugin follows the same pattern.
The plugin will override the provided access function. This means that TOTP verifications will be called first, and if successful, it will then call the original function and return its result. This approach ensures compatibility with role-based access control and other custom access patterns.
Payload adds some collections of its own after all plugins have run. When Payload initializes, the plugin wraps the ones that hold data — jobs and the global their schedules are computed from, folders and query presets — and writes to document locks; reading locks stays open, because the dashboard needs it, and so do per-user preferences. Collections added by plugins listed after this one are not wrapped, which is one more reason to keep it last. The jobs.access.run function behind /api/payload-jobs/run and /api/payload-jobs/handle-schedules is not wrapped either; if you run jobs that way, combine your check with totpAccess:
jobs: {
access: {
run: async (args) =>
args.req.headers.get('authorization') === `Bearer ${process.env.CRON_SECRET}` ||
(await totpAccess()(args)) === true,
},
},
There are cases where collections or globals need to be available for non-logged-in users. To handle this, the plugin provides disableAccessWrapper globally or per global/collection. If you have many collections/globals that provide custom access or public access, you should use the plugin options disableAccessWrapper.
In case of changing the default access, you can deactivate per collection/global:
import type { CollectionConfig } from 'payload'
export const posts: CollectionConfig = {
slug: 'posts',
access: {
read: () => true,
},
fields: [],
custom: {
totp: {
disableAccessWrapper: {
read: true,
},
},
},
}
The disableAccessWrapper from custom has the same type as access in Collection/Global based on the context.
In case that you need a more complex access, for example based on a header or auth user, you can import totpAccess:
import type { CollectionConfig } from 'payload'
import { totpAccess } from 'payload-totp'
export const posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
},
access: {
read: (args) => {
return (
args.req.headers.get('authorization') === 'Bearer 123' ||
totpAccess(({ req: { user } }) => Boolean(user))(args)
)
},
},
fields: [],
custom: {
totp: {
disableAccessWrapper: {
read: true,
},
},
},
}
A code has a million possible values with the default six digits, and the verification accepts the codes for the previous and next 30-second step as well, so an unlimited number of tries would eventually land on one. Every code submitted to /verify-totp and /remove-totp is therefore counted: after maxLoginAttempts codes without a correct one, codes are refused for lockTime, exactly as passwords are after that many wrong ones. The forms show Payload's usual "too many failed login attempts" message, and the lock is copied to the user so that the password login answers the same. Another user can lift the lock with Force Unlock on the locked user's edit view. A correct code clears the count.
Nothing needs to be configured: maxLoginAttempts and lockTime are the collection's own, and maxLoginAttempts: 0 turns the lockout off for codes as it does for passwords.
Payload's default unlock access is any logged-in user. Users cannot lift their own lock on codes, but any other account can, so restrict who unlocks, as you would to protect the password lockout:
access: {
unlock: ({ req: { user } }) => user?.role === 'admin',
},
The count and the lock are kept in a collection that the plugin adds, totp-attempts, with one document per user who has submitted a code, under the user's ID. They are not on the user document because Payload's login, refresh and logout write back the whole user document they read when they started, which would let someone who knows the password put the count back. The collection is hidden from the admin panel, left out of GraphQL, and its access functions refuse every request; it stays registered when the plugin is disabled, so the schema does not change with the option. On Postgres and SQLite it is a new table, totp_attempts, so create and run a migration when you upgrade.
After logging in, navigate to your account settings where you'll find the "Authenticator app" field. If forceSetup is enabled, you'll be automatically redirected to the Setup TOTP page.
Click the "Setup" button to proceed to the Setup TOTP Page:
Scan the QR code or copy the secret into your preferred authenticator app, then enter the generated PIN code.
Upon successful verification, you'll be redirected back to your account page.
Now, when you log out and log back in, you'll be prompted to enter your TOTP PIN code:
After entering the correct PIN, you'll be redirected to the main dashboard page.
payload auth
Replaces Payload's built-in auth with Better Auth, sharing one session between the admin panel and your app
590/wk344v3
payload better auth
Integrates the Better Auth library into Payload CMS 3 for social login, two-factor auth and role-based access
3474v3
payload better auth
Better Auth database adapter and plugins that replace Payload's local auth strategy
700/wk74v3
payload auth pwless
Passwordless login for Payload with passkeys, magic links, OAuth and refresh-token sessions
550/wk2v3
payload better auth
Replace Payload's built-in auth with Better Auth, including admin email OTP and req.user mapping
301v3
payload passkey
Passkey login and management for the Payload admin, built on Better Auth
65219/wk1v3