Why Traditional Multi-Factor Authentication (MFA) Is No Longer Enough
Multi-factor authentication has become a standard security recommendation, but not all MFA implementations are created equal. Traditional MFA methods, despite adding a security layer beyond passwords, contain critical vulnerabilities that sophisticated attackers regularly exploit. Traditional MFA methods - SMS codes, authenticator apps with time-based one-time passwords (TOTPs), and even email verification have proven vulnerable to sophisticated phishing attacks.
Why Traditional MFA Fails
SIM Swapping: When Your Phone Number Betrays You
This attack occurs when bad actors convince mobile carriers to transfer a victim’s phone number to a device they control. Once successful, they can intercept SMS-based verification codes meant for the legitimate user. This isn’t some obscure practice either, in 2022 CEO of Transform Ventures Michael Terpin won a $75 million lawsuit against a SIM-swapping perpetrator who stole millions in digital assets by hijacking his phone number. More information can be found on our dedicated SIM Swapping Post.
Phishing For Credentials
Even app-based authenticators like Google Authenticator or Authy can be compromised through well-crafted phishing campaigns. In 2023, the MGM Resorts cyberattack caused an estimated $100 million in damages despite having MFA in place. A group of bad actors used social engineering to manipulate MGM help desk employees into resetting MFA settings, granting them access to terabytes of data.
Social Engineering: Exploiting the Human
Human psychology remains the weakest link in security systems. The widely documented “0ktapus” campaign successfully targeted over 130 organizations, as reported by cybersecurity firm Group-IB, would target employees with convincing Okta login pages to steal credentials in real time. Victims who clicked the links and entered their credentials and MFA codes inadvertently gave attackers access to their corporate accounts, leading to significant data breaches at companies like Twilio and Cloudflare. Cloudflare’s own security incident report noted that employees using FIDO2-based hardware security keys remained protected while those using push notifications were compromised.
What Makes MFA Phishing-Resistant?
The core issue? These methods rely on shared secrets that can be intercepted or stolen. Phishing-resistant MFA eliminates reliance on shared secrets by leveraging asymmetric cryptography and binding authentication to specific origins (websites). This fundamentally changes the security model:
- Authentication is bound to specific domains, preventing credential reuse across different sites
- Private keys never leave the user’s device
- Biometric or physical presence verification ensures the legitimate user is present
In this guide, we’ll implement a bulletproof phishing-resistant MFA system using SuperTokens, WebAuthn, and FIDO2 standards. This approach not only strengthens security but also improves user experience by reducing friction.
Why WebAuthn?
Phishing remains one of the most effective attack vectors against traditional authentication. WebAuthn counters this by binding authentication directly to the user’s device through public-key cryptography. When users register with WebAuthn, their device generates a unique key pair for that service. The private key never leaves the device, while the public key is stored on the server. During authentication, the server sends a challenge only the correct private key can sign.
The critical phishing protection comes from origin binding - the browser ensures authentication requests can only come from the exact domain that registered the credential. Even perfect site clones at different URLs will fail because the origins don’t match. For developers, this means implementing authentication that protects users regardless of their susceptibility to phishing attempts.
How WebAuthn works
WebAuthn creates a secure authentication framework built on asymmetric cryptography. Instead of storing shared secrets like passwords on servers, it employs public-private key pairs. When users register their device generates these unique keys - the private key remains secured on the device while the public key is stored on the server.The absence of passwords eliminates common vulnerabilities like credential stuffing, password spraying, and database breaches. There’s simply no password to steal, reuse, or crack, removing entire categories of attacks from consideration.
User verification happens locally on the device through either biometrics (fingerprints, facial recognition) or hardware security keys. This verification proves the legitimate user is present without transmitting biometric data to the server. The local device handles all sensitive verification, then cryptographically signs the authentication challenge using the private key only after successful verification.
Prerequisites – What You Need to Get Started
Before diving into implementation, ensure you have:
- SuperTokens Core and a backend sdk installed (we’ll cover a quick setup if you haven’t)
- A WebAuthn-supported browser (Chrome, Firefox, Edge, Safari all have excellent support)
- A FIDO2 security key (YubiKey, SoloKey) or device with built-in biometric authentication (Windows Hello, Face ID, Touch ID)
- A basic web application (we’ll use React/Node.js, but the concepts apply to other stacks)
Let’s get started with a robust implementation that will protect your users from even the most sophisticated phishing attempts.
Step 1 – Setting Up SuperTokens for Authentication
-
Installing SuperTokens Core and Backend SDK. More details about the Core service and SDKs can be found at the Supertokens docs - here
npx create-supertokens-app@latest --recipe=emailpassword
-
Once everything has finished installing
cd
into the new project directory and runnpm start
. Visithttp://localhost:3000/auth
in your browser to see the demo app working.
Step 2 – Enabling WebAuthn for Passwordless, Phishing-Resistant MFA
Implementation
Note: Any project structure is based on the used cli command from above
We’ll be using the WebAuthn Recipe, more information at the quickstart guide. First update the frontend, find the config.tsx
file located in /frontend/src/config.tsx
.
- Import
WebAuthn
from the recipe - Update the
recipeList
forSuperTokensConfig
- Update the
PreBuiltUIList
to includeWebauthnPreBuiltUI
You’re file should look like the following:
Frontend config.tsx
import EmailPassword from "supertokens-auth-react/recipe/emailpassword";
import { EmailPasswordPreBuiltUI } from "supertokens-auth-react/recipe/emailpassword/prebuiltui";
import Session from "supertokens-auth-react/recipe/session";
import WebAuthn from "supertokens-auth-react/recipe/webauthn"; // passkeys
import { WebauthnPreBuiltUI } from 'supertokens-auth-react/recipe/webauthn/prebuiltui'; // passkeys
export function getApiDomain() {
const apiPort = import.meta.env.VITE_APP_API_PORT || 3001;
const apiUrl = import.meta.env.VITE_APP_API_URL || `http://localhost:${apiPort}`;
return apiUrl;
}
export function getWebsiteDomain() {
const websitePort = import.meta.env.VITE_APP_WEBSITE_PORT || 3000;
const websiteUrl = import.meta.env.VITE_APP_WEBSITE_URL || `http://localhost:${websitePort}`;
return websiteUrl;
}
export const SuperTokensConfig = {
appInfo: {
appName: "SuperTokens Demo App",
apiDomain: getApiDomain(),
websiteDomain: getWebsiteDomain(),
},
// recipeList contains all the modules that you want to
// use from SuperTokens. See the full list here: https://supertokens.com/docs/guides
recipeList: [
EmailPassword.init(),
WebAuthn.init(),
Session.init()
],
getRedirectionURL: async (context) => {
if (context.action === "SUCCESS" && context.newSessionCreated) {
return "/dashboard";
}
},
};
export const recipeDetails = {
docsLink: "https://supertokens.com/docs/emailpassword/introduction",
};
export const PreBuiltUIList = [
EmailPasswordPreBuiltUI,
WebauthnPreBuiltUI,
];
export const ComponentWrapper = (props: { children: JSX.Element }): JSX.Element => {
return props.children;
};
Now we’ll update the backend. Find the config.ts
file located in /backend/config.ts
, and Import WebAuthn from the recipe
import EmailPassword from "supertokens-node/recipe/emailpassword";
import Session from "supertokens-node/recipe/session";
import { TypeInput } from "supertokens-node/types";
import Dashboard from "supertokens-node/recipe/dashboard";
import UserRoles from "supertokens-node/recipe/userroles";
import WebAuthn from "supertokens-node/recipe/webauthn"; // enables passkeys
export function getApiDomain() {
const apiPort = process.env.VITE_APP_API_PORT || 3001;
const apiUrl = process.env.VITE_APP_API_URL || `http://localhost:${apiPort}`;
return apiUrl;
}
export function getWebsiteDomain() {
const websitePort = process.env.VITE_APP_WEBSITE_PORT || 3000;
const websiteUrl = process.env.VITE_APP_WEBSITE_URL || `http://localhost:${websitePort}`;
return websiteUrl;
}
export const SuperTokensConfig: TypeInput = {
framework: "koa",
supertokens: {
// this is the location of the SuperTokens core.
connectionURI: "https://try.supertokens.com",
},
appInfo: {
// learn more about this on https://supertokens.com/docs/thirdpartyemailpassword/appinfo
appName: "SuperTokens Koa demo app",
apiDomain: getApiDomain(),
websiteDomain: getWebsiteDomain(),
},
recipeList: [
EmailPassword.init(),
WebAuthn.init(),
Session.init(),
Dashboard.init(),
UserRoles.init()
],
};
Navigate to http://localhost:3000/auth you’ll see a new option to use the passkey as an auth option
Enforcing Phishing-Resistant MFA Policies
To enable mfa we’ll use the MFA recipe to require multi-factor authentication, currently mfa has the support for Email/SMS One-Time Password (OTP) or Time-based One-Time Password (TOTP). Just like above we’ll be adding recipes to both the front and backend config files.
Frontend adding mfa config.tsx
import EmailPassword from "supertokens-auth-react/recipe/emailpassword";
import { EmailPasswordPreBuiltUI } from "supertokens-auth-react/recipe/emailpassword/prebuiltui";
import Session from "supertokens-auth-react/recipe/session";
import WebAuthn from "supertokens-auth-react/recipe/webauthn"; // passkeys
import { WebauthnPreBuiltUI } from 'supertokens-auth-react/recipe/webauthn/prebuiltui'; // passkeys
// mfa
import MultiFactorAuth from "supertokens-auth-react/recipe/multifactorauth";
import { MultiFactorAuthPreBuiltUI } from "supertokens-auth-react/recipe/multifactorauth/prebuiltui";
import Passwordless from "supertokens-auth-react/recipe/passwordless";
import { PasswordlessPreBuiltUI } from "supertokens-auth-react/recipe/passwordless/prebuiltui";
import TOTP from "supertokens-auth-react/recipe/totp";
import { TOTPPreBuiltUI } from "supertokens-auth-react/recipe/totp/prebuiltui";
export function getApiDomain() {
const apiPort = import.meta.env.VITE_APP_API_PORT || 3001;
const apiUrl = import.meta.env.VITE_APP_API_URL || `http://localhost:${apiPort}`;
return apiUrl;
}
export function getWebsiteDomain() {
const websitePort = import.meta.env.VITE_APP_WEBSITE_PORT || 3000;
const websiteUrl = import.meta.env.VITE_APP_WEBSITE_URL || `http://localhost:${websitePort}`;
return websiteUrl;
}
export const SuperTokensConfig = {
appInfo: {
appName: "SuperTokens Demo App",
apiDomain: getApiDomain(),
websiteDomain: getWebsiteDomain(),
},
// recipeList contains all the modules that you want to
// use from SuperTokens. See the full list here: https://supertokens.com/docs/guides
recipeList: [
EmailPassword.init(),
WebAuthn.init(),
Passwordless.init({
contactMethod: "EMAIL_OR_PHONE",
}),
MultiFactorAuth.init({
firstFactors: ["webauthn", "emailpassword"]
}),
TOTP.init(),
Session.init()
],
getRedirectionURL: async (context) => {
if (context.action === "SUCCESS" && context.newSessionCreated) {
return "/dashboard";
}
},
};
export const recipeDetails = {
docsLink: "https://supertokens.com/docs/emailpassword/introduction",
};
export const PreBuiltUIList = [
EmailPasswordPreBuiltUI,
WebauthnPreBuiltUI,
PasswordlessPreBuiltUI,
MultiFactorAuthPreBuiltUI,
TOTPPreBuiltUI
];
export const ComponentWrapper = (props: { children: JSX.Element }): JSX.Element => {
return props.children;
};
Backend adding mfa config.tsx
import EmailPassword from "supertokens-node/recipe/emailpassword";
import Session from "supertokens-node/recipe/session";
import { TypeInput } from "supertokens-node/types";
import Dashboard from "supertokens-node/recipe/dashboard";
import UserRoles from "supertokens-node/recipe/userroles";
import WebAuthn from "supertokens-node/recipe/webauthn"; // enables passkeys
// mfa imports
import AccountLinking from "supertokens-node/recipe/accountlinking";
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";
import TOTP from "supertokens-node/recipe/totp";
import Passwordless from "supertokens-node/recipe/passwordless";
export function getApiDomain() {
const apiPort = process.env.VITE_APP_API_PORT || 3001;
const apiUrl = process.env.VITE_APP_API_URL || `http://localhost:${apiPort}`;
return apiUrl;
}
export function getWebsiteDomain() {
const websitePort = process.env.VITE_APP_WEBSITE_PORT || 3000;
const websiteUrl = process.env.VITE_APP_WEBSITE_URL || `http://localhost:${websitePort}`;
return websiteUrl;
}
export const SuperTokensConfig: TypeInput = {
framework: "koa",
supertokens: {
// this is the location of the SuperTokens core.
connectionURI: "https://try.supertokens.com",
},
appInfo: {
// learn more about this on https://supertokens.com/docs/thirdpartyemailpassword/appinfo
appName: "SuperTokens Koa demo app",
apiDomain: getApiDomain(),
websiteDomain: getWebsiteDomain(),
},
recipeList: [
EmailPassword.init(),
WebAuthn.init(),
Passwordless.init({
contactMethod: "EMAIL_OR_PHONE",
flowType: "USER_INPUT_CODE_AND_MAGIC_LINK",
}),
AccountLinking.init({
shouldDoAutomaticAccountLinking: async (newAccountInfo: AccountInfoWithRecipeId & { recipeUserId?: RecipeUserId }, user: User | undefined, session: SessionContainerInterface | undefined, tenantId: string, userContext: UserContext) => {
if (session === undefined) {
// we do not want to do first factor account linking by default. To enable that,
// please see the automatic account linking docs in the recipe docs for your first factor.
return {
shouldAutomaticallyLink: false
};
}
if (user === undefined || session.getUserId() === user.id) {
// if it comes here, it means that a session exists, and we are trying to link the
// newAccountInfo to the session user, which means it's an MFA flow, so we enable
// linking here.
return {
shouldAutomaticallyLink: true,
shouldRequireVerification: false
}
}
return {
shouldAutomaticallyLink: false
};
}
}),
MultiFactorAuth.init({
firstFactors: ["webauthn", "emailpassword"],
override: {
functions: (oI) => ({
...oI,
getMFARequirementsForAuth: () => [
{
oneOf: [
MultiFactorAuth.FactorIds.TOTP,
MultiFactorAuth.FactorIds.OTP_EMAIL,
MultiFactorAuth.FactorIds.OTP_PHONE,
],
},
],
}),
},
}),
TOTP.init(),
Session.init(),
Dashboard.init(),
UserRoles.init()
],
};
Testing Your MFA Implementation
Unit Tests
Let’s add some unit tests to test the flow - Sign up, Session verification, Refreshing session tokens, and Logout. Create a test directory in the backend /backend/__tests__
Backend auth-flow.test.ts
import SuperTokens from 'supertokens-node';
import EmailPassword from 'supertokens-node/recipe/emailpassword';
import Session from 'supertokens-node/recipe/session';
import { SessionContainerInterface } from 'supertokens-node/recipe/session/types';
import { User } from 'supertokens-node/types';
// Mock dependencies
jest.mock('supertokens-node', () => ({
init: jest.fn(),
}));
jest.mock('supertokens-node/recipe/emailpassword', () => ({
signUp: jest.fn(),
signIn: jest.fn(),
}));
jest.mock('supertokens-node/recipe/session', () => ({
createNewSession: jest.fn(),
getSession: jest.fn(),
revokeAllSessionsForUser: jest.fn(),
}));
describe('Authentication Flow Tests', () => {
// Comprehensive mock user data
const mockUser: User = {
id: 'test_st',
emails: ['[email protected]'],
timeJoined: Date.now(),
isPrimaryUser: true,
tenantIds: ['default'],
phoneNumbers: [],
thirdParty: null,
loginMethods: [],
webauthn: {
credentialIds: ['mock-credential-id'],
},
toJson: () => ({
id: 'test_st',
emails: ['[email protected]'],
}),
};
// Mock session container
const createMockSessionContainer = (userId: string): SessionContainerInterface => ({
revokeSession: jest.fn(),
getSessionDataFromDatabase: jest.fn(),
updateSessionDataInDatabase: jest.fn(),
getUserId: () => userId,
getAccessToken: () => 'mock-access-token',
getHandle: () => 'mock-session-handle',
getRecipeUserId: jest.fn().mockReturnValue({ getAsString: () => userId }),
getTenantId: jest.fn().mockReturnValue('default'),
getAccessTokenPayload: jest.fn().mockReturnValue({}),
getAllSessionTokensDangerously: jest.fn().mockReturnValue({
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
}),
mergeIntoAccessTokenPayload: jest.fn(),
getTimeCreated: jest.fn().mockReturnValue(Date.now()),
getExpiry: jest.fn().mockReturnValue(Date.now() + 3600000),
assertClaims: jest.fn(),
// Adding missing methods with mock implementations
fetchAndSetClaim: jest.fn(),
setClaimValue: jest.fn(),
getClaimValue: jest.fn(),
removeClaim: jest.fn(),
attachToRequestResponse: jest.fn(),
});
// Reset all mocks before each test
beforeEach(() => {
jest.clearAllMocks();
});
// Utility function to create sign up/in response
const createSuccessResponse = () => ({
status: "OK" as const,
user: mockUser,
recipeUserId: {
getAsString: () => mockUser.id,
},
});
// Test 1: Sign Up
test('should successfully sign up a new user', async () => {
// Arrange
const mockSignUpResponse = createSuccessResponse();
const mockAlreadyExistsResponse = {
status: "EMAIL_ALREADY_EXISTS_ERROR" as const
};
(EmailPassword.signUp as jest.Mock)
.mockImplementation((tenantId, email, password) => {
if (email === '[email protected]') {
return Promise.resolve(mockSignUpResponse);
}
if (email === '[email protected]') {
return Promise.resolve(mockAlreadyExistsResponse);
}
throw new Error('Unexpected email');
});
const mockSession = createMockSessionContainer(mockUser.id);
(Session.createNewSession as jest.Mock).mockResolvedValue(mockSession);
// Act
const signUpResult = await EmailPassword.signUp(
'tenant-id',
'[email protected]',
'Test123!'
);
// Assert
expect(signUpResult).toEqual(expect.objectContaining({
status: "OK",
user: expect.objectContaining({
emails: expect.arrayContaining(['[email protected]'])
})
}));
// Try signing up with existing email
const existingEmailResult = await EmailPassword.signUp(
'tenant-id',
'[email protected]',
'Test123!'
);
expect(existingEmailResult).toEqual({
status: "EMAIL_ALREADY_EXISTS_ERROR"
});
});
// Test 2: Session Verification
test('should get user session', async () => {
// Arrange
const mockSession = createMockSessionContainer(mockUser.id);
(Session.getSession as jest.Mock).mockResolvedValue(mockSession);
// Act
const sessionResult = await Session.getSession(
{} as any, // req
{} as any, // res
{} // options
);
// Assert
expect(sessionResult.getUserId()).toBe(mockUser.id);
expect(Session.getSession).toHaveBeenCalled();
});
// Test 3: Revoking Sessions
test('should revoke all sessions for a user', async () => {
// Arrange
const mockRevokeResponse: string[] = [mockUser.id];
(Session.revokeAllSessionsForUser as jest.Mock).mockResolvedValue(mockRevokeResponse);
// Act
const revokeResult = await Session.revokeAllSessionsForUser(mockUser.id);
// Assert
expect(revokeResult).toEqual([mockUser.id]);
expect(Session.revokeAllSessionsForUser).toHaveBeenCalledWith(mockUser.id);
});
// Test 4: Sign In
test('should successfully sign in an existing user', async () => {
// Arrange
const mockSignInResponse = createSuccessResponse();
const mockWrongCredentialsResponse = {
status: "WRONG_CREDENTIALS_ERROR" as const
};
(EmailPassword.signIn as jest.Mock)
.mockImplementation((tenantId, email, password) => {
if (email === '[email protected]') {
return Promise.resolve(mockSignInResponse);
}
if (email === '[email protected]') {
return Promise.resolve(mockWrongCredentialsResponse);
}
throw new Error('Unexpected email');
});
const mockSession = createMockSessionContainer(mockUser.id);
(Session.createNewSession as jest.Mock).mockResolvedValue(mockSession);
// Act
const signInResult = await EmailPassword.signIn(
'tenant-id',
'[email protected]',
'Test123!'
);
// Assert
expect(signInResult).toEqual(expect.objectContaining({
status: "OK",
user: expect.objectContaining({
emails: expect.arrayContaining(['[email protected]'])
})
}));
// Try signing in with wrong credentials
const wrongCredentialsResult = await EmailPassword.signIn(
'tenant-id',
'[email protected]',
'WrongPass123!'
);
expect(wrongCredentialsResult).toEqual({
status: "WRONG_CREDENTIALS_ERROR"
});
});
// Error Handling Tests
describe('Error Scenarios', () => {
test('should handle sign up failure', async () => {
// Arrange
const mockError = new Error('Sign up failed');
(EmailPassword.signUp as jest.Mock).mockRejectedValue(mockError);
// Act & Assert
await expect(EmailPassword.signUp(
'tenant-id',
'[email protected]',
'Test123!'
)).rejects.toThrow('Sign up failed');
});
test('should handle session retrieval failure', async () => {
// Arrange
const mockError = new Error('Invalid session');
(Session.getSession as jest.Mock).mockRejectedValue(mockError);
// Act & Assert
await expect(Session.getSession(
{} as any, // req
{} as any, // res
{} // options
)).rejects.toThrow('Invalid session');
});
});
});
Updates also need to be made to the package.json
to include our test packages and to add the cmd npm test
package.json
{
"scripts": {
"start": "npx vite-node ./main.ts",
"lint": "eslint .",
"build": "tsc",
"test": "jest"
},
"dependencies": {
"@koa/cors": "^5.0.0",
"koa": "^2.15.3",
"koa-router": "^12.0.0",
"supertokens-node": "latest",
"typescript": "^4.7.2"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/jest": "^29.5.11",
"@types/koa__cors": "^4.0.0",
"@types/koa-router": "^7.4.4",
"@types/node": "^20.11.0",
"@types/node-fetch": "^2.6.11",
"axios": "^1.6.2",
"eslint": "^9.17.0",
"globals": "^15.13.0",
"jest": "^29.7.0",
"node-fetch": "^2.7.0",
"qs": "^6.11.2",
"ts-jest": "^29.1.1",
"typescript-eslint": "^8.18.1",
"vite-node": "^2.1.8",
"jest-mock": "^29.7.0",
"@types/jest": "^29.5.11"
}
}
Run npm install
then npm test
It’s worth noting the unit tests we added are mocking the api calls. In an enterprise environment ideally there would also be a staging/canary environment that allows full live end-to-end testing for the service.
Logging and Metrics
Logging and Metrics are two important aspects every service should have. They help provide a clear picture into your system to better scale infra or track down a loose bug.
Looking at /backend/main.ts
we’ll track how many times a user has logged in, maybe this can provide an insight into strange user behavior to keep an eye out for:
Backend main.ts
import Koa from "koa";
import cors from "@koa/cors";
import supertokens from "supertokens-node";
import { middleware } from "supertokens-node/framework/koa";
import { getWebsiteDomain, SuperTokensConfig } from "./config";
import KoaRouter from "koa-router";
import { verifySession } from "supertokens-node/recipe/session/framework/koa";
import { SessionContext } from "supertokens-node/framework/koa";
import Multitenancy from "supertokens-node/recipe/multitenancy";
import { deleteUser } from "supertokens-node";
// basic structures to hold our metrics
interface AuthAttempt {
time: string;
userId: string;
status: string;
}
const authLogs = {
success: 0,
recentAttempts: [] as AuthAttempt[]
};
supertokens.init(SuperTokensConfig);
const app = new Koa();
const router = new KoaRouter();
app.use(
cors({
origin: getWebsiteDomain(),
allowHeaders: ["content-type", ...supertokens.getAllCORSHeaders()],
credentials: true,
})
);
// This exposes all the APIs from SuperTokens to the client.
app.use(middleware());
// This endpoint can be accessed regardless of
// having a session with SuperTokens
router.get("/hello", (ctx: SessionContext) => {
ctx.body = "hello";
});
// An example API that requires session verification
router.get("/sessioninfo", verifySession(), (ctx: SessionContext) => {
const userId = ctx.session!.getUserId();
// Log successful authentication
authLogs.success++;
authLogs.recentAttempts.push({
time: new Date().toISOString(),
userId,
status: "success"
});
ctx.status = 200;
ctx.body = {
userId,
status: "ok",
authStats: authLogs
};
});
// This API is used by the frontend to create the tenants drop down when the app loads.
// Depending on your UX, you can remove this API.
router.get("/tenants", async (ctx: SessionContext) => {
const tenants = await Multitenancy.listAllTenants();
ctx.body = JSON.stringify({ tenants }, null, 4);
});
app.use(router.routes());
if (!module.parent) app.listen(3001, () => console.log("API Server listening on port 3001"));
Each recipe on the frontend /frontend/src/config.tsx
has a onHandleEvent
to help log what is currently happening in the system.
Frontend config.tsx
import EmailPassword from "supertokens-auth-react/recipe/emailpassword";
import { EmailPasswordPreBuiltUI } from "supertokens-auth-react/recipe/emailpassword/prebuiltui";
import Session from "supertokens-auth-react/recipe/session";
import WebAuthn from "supertokens-auth-react/recipe/webauthn"; // passkeys
import { WebauthnPreBuiltUI } from 'supertokens-auth-react/recipe/webauthn/prebuiltui'; // passkeys
// mfa
import MultiFactorAuth from "supertokens-auth-react/recipe/multifactorauth";
import { MultiFactorAuthPreBuiltUI } from "supertokens-auth-react/recipe/multifactorauth/prebuiltui";
import Passwordless from "supertokens-auth-react/recipe/passwordless";
import { PasswordlessPreBuiltUI } from "supertokens-auth-react/recipe/passwordless/prebuiltui";
import TOTP from "supertokens-auth-react/recipe/totp";
import { TOTPPreBuiltUI } from "supertokens-auth-react/recipe/totp/prebuiltui";
export function getApiDomain() {
const apiPort = import.meta.env.VITE_APP_API_PORT || 3001;
const apiUrl = import.meta.env.VITE_APP_API_URL || `http://localhost:${apiPort}`;
return apiUrl;
}
export function getWebsiteDomain() {
const websitePort = import.meta.env.VITE_APP_WEBSITE_PORT || 3000;
const websiteUrl = import.meta.env.VITE_APP_WEBSITE_URL || `http://localhost:${websitePort}`;
return websiteUrl;
}
export const SuperTokensConfig = {
appInfo: {
appName: "SuperTokens Demo App",
apiDomain: getApiDomain(),
websiteDomain: getWebsiteDomain(),
},
// recipeList contains all the modules that you want to
// use from SuperTokens. See the full list here: https://supertokens.com/docs/guides
recipeList: [
EmailPassword.init(),
WebAuthn.init(),
Passwordless.init({
contactMethod: "EMAIL_OR_PHONE",
onHandleEvent: (context) => {
// Track session events
if (context.action === "SUCCESS") {
if (context.createdNewSession) {
let user = context.user;
if (context.isNewRecipeUser && context.user.loginMethods.length === 1) {
// sign up success
console.log("sign up was a success")
} else {
// sign in success
console.log("sign in was a success - no second auth")
}
} else {
// during step up or second factor auth with email password
console.log("sign in was a success - second factor auth")
}
}
}
}),
MultiFactorAuth.init({
firstFactors: ["webauthn", "emailpassword"]
}),
TOTP.init(),
Session.init()
],
getRedirectionURL: async (context) => {
if (context.action === "SUCCESS" && context.newSessionCreated) {
return "/dashboard";
}
},
};
export const recipeDetails = {
docsLink: "https://supertokens.com/docs/emailpassword/introduction",
};
export const PreBuiltUIList = [
EmailPasswordPreBuiltUI,
WebauthnPreBuiltUI,
PasswordlessPreBuiltUI,
MultiFactorAuthPreBuiltUI,
TOTPPreBuiltUI
];
export const ComponentWrapper = (props: { children: JSX.Element }): JSX.Element => {
return props.children;
};
How this setup prevents a phishing attack
Traditional authentication methods are vulnerable because they rely on shared secrets that can be intercepted. To illustrate how the WebAuthn implementation protects against these attacks, let’s simulate a common phishing scenario and see how our SuperTokens + WebAuthn setup renders it ineffective.
In a typical phishing attack targeting traditional MFA:
- An attacker creates a convincing clone of your login page
- They send users a link to this fake site (e.g.,
my-legit-app-secure.com
instead ofmylegitapp.com
) - When users enter credentials and MFA codes, the attacker captures them in real-time
- The attacker uses these stolen credentials to access the real site
Even with traditional MFA like SMS codes or authenticator apps, this attack works because the attacker can simply forward the stolen credentials and MFA codes to the legitimate service immediately after capturing them.
This isn’t just theoretical protection. Remember the Cloudflare incident mentioned earlier? Their own security report explicitly stated that employees using FIDO2-based WebAuthn keys (the same standard we’re implementing) remained protected while those using push notifications were compromised.
By requiring phishing-resistant MFA with WebAuthn as we’ve implemented, you’re effectively removing phishing as a viable attack vector against your authentication system. Even if users are tricked into visiting a fake site, the browser’s security model prevents the attack from succeeding.
How SuperTokens Elevates Phishing-Resistant MFA
Why Use SuperTokens Instead of Rolling Your Own?
Authentication seems straightforward on the surface—verify identity and grant access—but implementing it securely involves intricate technical challenges. Many developers underestimate these complexities until they encounter serious security issues in production.
Building your own authentication system from scratch requires managing numerous critical components:
- Security vulnerabilities: Without specialized knowledge, your system could be vulnerable to common attacks like CSRF, XSS, session fixation, and credential stuffing
- Compliance requirements: Meeting standards like GDPR, HIPAA, SOC2, and other regulations requires domain expertise that’s expensive to develop and maintain in-house
- Ongoing maintenance: Authentication isn’t a “build once and forget” feature—it requires constant updates to address evolving security threats and browser compatibility issues
- Developer resources: Building robust authentication diverts valuable engineering time from your core product features and business logic
- Edge cases: Authentication has countless edge cases around account recovery, device management, and session handling that you’ll need to solve
SuperTokens’ Advanced Security Features for Phishing-Resistant MFA
Modern authentication systems rely on tokens to maintain user sessions, but these tokens become prime targets for attackers. SuperTokens implements sophisticated session management that mitigates token theft.
SuperTokens implements automatic token rotation, significantly reducing the window of opportunity for attackers if a token is somehow compromised:
- Access tokens have short lifespans
- Refresh tokens are automatically rotated with each use
- The rotation system maintains session continuity while limiting exposure
Seamless Integration with WebAuthn & FIDO2
SuperTokens’ implementation creates a frictionless bridge between traditional authentication flows and phishing-resistant standards. This means users can authenticate using:
- Built-in biometrics (TouchID, FaceID, Windows Hello)
- Platform authenticators (Android/Iphone fingerprint sensors)
- External security keys (YubiKey, Titan Security Key)
Flexible Architecture: Enforce MFA for Specific Users/Roles
SuperTokens provides granular control over authentication requirements without complex custom code. With Role-Based MFA Policies you can implement different security requirements based on user roles. This enables you to:
- Enforce hardware security keys for administrators
- Require second factors only for sensitive operations
- Implement different policies based on user risk profiles
SuperTokens makes it easy to require additional authentication for specific actions. This allows you to:
- Require additional factors for high-value transactions
- Implement risk-based authentication policies
- Verify identity before sensitive account changes
The system handles common edge cases like authenticator loss. This provides secure recovery options while maintaining security:
- Email/phone fallback mechanisms
- Backup code generation for account recovery
- Administrator-assisted recovery workflows
By leveraging SuperTokens’ flexible architecture, you can implement authentication policies that balance security with usability, applying the appropriate level of protection based on user role, action sensitivity, and risk assessment.
In the next section, we’ll explore how to deploy this phishing-resistant MFA system at scale and integrate it with existing infrastructure.
Final Thoughts & Next Steps
Taking Phishing-Resistant Authentication to the Next Level
For organizations with stringent security requirements, SuperTokens offers device attestation capabilities that can be leveraged for:
- Verifies authenticator is from a trusted manufacturer
- Ensures hardware-backed key storage
- Requires user verification (biometric or PIN)
- Prevents credential cloning between devices
Device attestation enables security teams to enforce hardware security key policies and maintain an audit trail of registered authenticators—critical for regulated industries.
Encouraging User Adoption: Making WebAuthn Onboarding Seamless
The most secure authentication is useless if users can’t or won’t use it. SuperTokens provides customizable UI components that guide users through adopting phishing-resistant authentication, making the entire process as low friction as possible.
Best practices for increasing adoption include:
- Explaining the possible costs that come with a phishing attack
- Creating fallback mechanisms for lost devices
- Gradually transitioning from traditional MFA to phishing-resistant options
Further Reading: SuperTokens Documentation
The implementation we’ve explored is just the beginning. SuperTokens offers extensive documentation for advanced configurations:
- WebAuthn Implementation Guide - Detailed setup instructions
- Multi-factor Authentication - Advanced policies and flows
- User Roles and Permissions - Role-based authentication requirements
Conclusion
By implementing phishing-resistant MFA with SuperTokens and WebAuthn, you’ve addressed one of the most persistent security vulnerabilities facing modern applications. This approach not only protects your users from sophisticated attacks but also improves their authentication experience.
The combination of SuperTokens’ flexible architecture, WebAuthn’s cryptographic security, and the FIDO2 standards creates a defense-in-depth strategy that eliminates entire categories of authentication attacks while providing the granular control needed to balance security with usability.
As phishing attacks continue to evolve in sophistication, this implementation ensures your authentication system remains resilient against current and future threats.