Growth engineering skill
Quick answer
- 01What is it?
- A/B testing infrastructure, feature flags (LaunchDarkly, Unleash), experimentation platforms, PLG patterns, and funnel optimization. Its edge is a particular angle on growth marketing, giving the agent tighter constraints than a plain growth engineering skill request.
- 02Inputs
- Context for growth marketing: your goals, audience, constraints, and any source material the skill asks for.
- 03Output
- A ready-to-use result for growth marketing: the analysis, copy, or recommendations the agent produces.
Add this skill
Install as a package
Installs this one skill package for your coding agent, including any supporting files that skill ships with โ not every skill in the repository. Read the tutorial.
$ npx skills add travisjneuman/.claude --skill growth-engineeringSkill instructions
The instruction file for this skill. The skill also includes other files you need to install to use it.
Growth Engineering Skill
Infrastructure and patterns for product-led growth, experimentation, and conversion optimization.
Feature Flag Systems
Implementation Pattern
// lib/feature-flags.ts
import { PostHog } from 'posthog-node';
const posthog = new PostHog(process.env.POSTHOG_API_KEY!);
interface FeatureFlags {
'new-onboarding-flow': boolean;
'pricing-experiment': 'control' | 'variant-a' | 'variant-b';
'ai-suggestions': boolean;
}
export async function getFlag<K extends keyof FeatureFlags>(
key: K,
userId: string,
): Promise<FeatureFlags[K]> {
const value = await posthog.getFeatureFlag(key, userId);
return value as FeatureFlags[K];
}
// Usage in component
const showNewOnboarding = await getFlag('new-onboarding-flow', user.id);
Feature Flag Best Practices
- Short-lived flags: Remove after experiment concludes (< 2 weeks)
- Long-lived flags: Ops toggles for gradual rollouts, kill switches
- Never nest feature flags (creates exponential complexity)
- Clean up stale flags monthly
- Log flag evaluations for debugging
A/B Testing Infrastructure
Experiment Design
// lib/experiments.ts
interface Experiment {
id: string;
name: string;
variants: {
id: string;
weight: number; // 0-100, must sum to 100
}[];
targetAudience: {
percentage: number; // % of users included
filters?: Record<string, unknown>;
};
primaryMetric: string;
secondaryMetrics: string[];
minimumSampleSize: number;
startDate: Date;
endDate?: Date;
}
// Track experiment exposure
function trackExposure(experimentId: string, variantId: string, userId: string) {
analytics.capture({
event: '$experiment_started',
distinctId: userId,
properties: {
$experiment_id: experimentId,
$variant_id: variantId,
},
});
}
Statistical Significance
- Minimum sample size: Calculate before starting (use Evan Miller calculator)
- Don't peek: Set duration upfront, don't stop early on promising results
- Sequential testing: Use if you must check early (adjusts p-values)
- Minimum detectable effect: Define what improvement matters (e.g., 5% lift)
Product-Led Growth Patterns
Activation Metrics
| Stage | Metric | Example |
|---|---|---|
| Sign up | Registration complete | User creates account |
| Setup | Profile complete | Fills required fields |
| Aha moment | Core value experienced | Creates first project |
| Habit | Repeated engagement | 3 sessions in first week |
| Revenue | Conversion to paid | Subscribes to plan |
Viral Loops
// Referral system pattern
interface Referral {
referrerId: string;
referredEmail: string;
status: 'pending' | 'signed_up' | 'activated' | 'converted';
rewardGranted: boolean;
}
// Track referral funnel
function trackReferralStep(referralId: string, step: Referral['status']) {
analytics.capture({
event: 'referral_step',
properties: { referralId, step },
});
}
Conversion Optimization
- Reduce friction: Minimize form fields, enable social login
- Social proof: Show user counts, testimonials, logos
- Urgency: Trial countdown, limited-time offers (use sparingly)
- Value demonstration: Interactive demos, free tier with clear upgrade path
- Personalization: Onboarding flow based on use case selection
Growth Metrics
| Metric | Formula | Target |
|---|---|---|
| Activation rate | Activated / Signed up | > 40% |
| Trial-to-paid | Paid / Trial started | > 15% |
| Net revenue retention | (Start MRR + Expansion - Contraction - Churn) / Start MRR | > 110% |
| Viral coefficient | Invites sent * Conversion rate | > 0.5 |
| Time to value | Median time from signup to aha moment | < 5 min |
| DAU/MAU ratio | Daily active / Monthly active | > 20% |
Experimentation Platforms
| Platform | Type | Best For |
|---|---|---|
| PostHog | Self-hosted/cloud | Full-stack, open source |
| LaunchDarkly | Cloud | Feature flags at scale |
| Statsig | Cloud | Auto-stats, warehouse-native |
| Growthbook | Self-hosted/cloud | Open source, Bayesian stats |
| Optimizely | Cloud | Enterprise, multi-channel |
Related Resources
~/.claude/skills/product-analytics/SKILL.md- Analytics and tracking~/.claude/agents/product-analytics-specialist.md- Analytics agent~/.claude/skills/authentication-patterns/SKILL.md- Auth for PLG
Measure everything. Experiment constantly. Remove what doesn't work.
Supporting file: agents/product-analytics-specialist.md
Product Analytics Specialist Agent
Expert product analytics engineer specializing in event tracking architecture, analytics SDK integration, funnel analysis, cohort analysis, A/B testing infrastructure, and data privacy compliance.
Capabilities
Event Tracking Architecture
- Event taxonomy design (noun-verb naming conventions)
- Event schema validation and governance
- User identification and aliasing
- Group analytics (B2B account-level)
- Custom properties and super properties
- Server-side vs client-side tracking trade-offs
Analytics SDKs & Platforms
- PostHog (self-hosted and cloud, feature flags, session replay)
- Mixpanel (event analytics, funnels, retention)
- Amplitude (product analytics, behavioral cohorts)
- Segment (customer data platform, event routing)
- Google Analytics 4 (web analytics, ecommerce)
- Plausible/Fathom (privacy-first alternatives)
Funnel Analysis
- Conversion funnel design and implementation
- Drop-off analysis and optimization
- Multi-step funnel tracking
- Time-to-convert metrics
- Funnel comparison across segments
Cohort Analysis
- Behavioral cohorts (actions-based)
- Temporal cohorts (signup date-based)
- Retention analysis (D1, D7, D30)
- Cohort comparison and trends
- Lifecycle analysis (new, returning, dormant, resurrected)
A/B Testing & Experimentation
- Feature flag infrastructure
- Experiment design (control, variants, sample size)
- Statistical significance calculation
- Multi-variate testing
- Rollout strategies (percentage, user attributes)
- Guardrail metrics
Data Privacy
- GDPR consent management
- Data anonymization and pseudonymization
- User data deletion (right to be forgotten)
- Cookie consent and tracking opt-out
- Server-side tracking for privacy compliance
When to Use This Agent
- Designing an event tracking plan for a new product
- Integrating PostHog, Mixpanel, or Amplitude
- Setting up conversion funnels
- Implementing A/B testing with feature flags
- Designing event schemas and naming conventions
- Setting up Segment for event routing
- Ensuring analytics comply with GDPR/CCPA
- Creating custom dashboards and reports
Instructions
When working on analytics tasks:
- Design the tracking plan first: Before writing code, define the event taxonomy, naming conventions, and key metrics. A tracking plan document prevents inconsistent events.
- Use a naming convention: Adopt a consistent pattern like
object_action(e.g.,page_viewed,button_clicked,purchase_completed). - Track events server-side when possible: Server-side events are more reliable than client-side (no ad blockers, consistent data).
- Respect user privacy: Always implement consent before tracking. Anonymize where possible. Never track PII in event properties without consent.
- Validate events in development: Use analytics debuggers and event validators to catch issues before production.
Key Patterns
Event Tracking Plan Document
# Tracking Plan
## Naming Convention
- Format: `object_action` (snake_case)
- Objects: page, button, form, modal, feature, purchase
- Actions: viewed, clicked, submitted, opened, closed, completed, failed
## Core Events
| Event Name | Trigger | Properties | Priority |
|------------|---------|------------|----------|
| page_viewed | Any page load | page_name, page_path, referrer | P0 |
| signup_started | Signup form shown | source, variant | P0 |
| signup_completed | Account created | method (email/google/github) | P0 |
| feature_used | Core feature action | feature_name, context | P0 |
| purchase_completed | Payment success | plan, amount, currency, trial | P0 |
| button_clicked | CTA interaction | button_name, page, position | P1 |
| search_performed | Search executed | query, results_count | P1 |
| error_encountered | Error shown to user | error_type, error_message, page | P1 |
## User Properties (set once or updated)
| Property | Type | Description |
|----------|------|-------------|
| plan | string | Current subscription plan |
| signup_date | datetime | When user signed up |
| company_size | string | Self-reported company size |
| role | string | User's role in the product |
## Group Properties (B2B account-level)
| Property | Type | Description |
|----------|------|-------------|
| company_name | string | Organization name |
| plan | string | Account plan level |
| mrr | number | Monthly recurring revenue |
| employee_count | number | Number of seats |
PostHog Integration (Next.js)
// lib/posthog.ts
import posthog from 'posthog-js';
export function initPostHog(): void {
if (typeof window === 'undefined') return;
if (posthog.__loaded) return;
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com',
capture_pageview: false, // We handle this manually for SPA
capture_pageleave: true,
persistence: 'localStorage+cookie',
loaded: (ph) => {
if (process.env.NODE_ENV === 'development') {
ph.debug();
}
},
});
}
export { posthog };
// providers/analytics-provider.tsx
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { initPostHog, posthog } from '@/lib/posthog';
export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
initPostHog();
}, []);
useEffect(() => {
if (!pathname) return;
const url = searchParams?.toString()
? `${pathname}?${searchParams.toString()}`
: pathname;
posthog.capture('$pageview', { $current_url: url });
}, [pathname, searchParams]);
return <>{children}</>;
}
Event Tracking Utilities
// lib/analytics.ts
import { posthog } from '@/lib/posthog';
// Type-safe event tracking
type EventMap = {
signup_started: { source: string; variant?: string };
signup_completed: { method: 'email' | 'google' | 'github' };
feature_used: { feature_name: string; context?: string };
purchase_completed: {
plan: string;
amount: number;
currency: string;
trial: boolean;
};
button_clicked: { button_name: string; page: string; position?: string };
search_performed: { query: string; results_count: number };
error_encountered: {
error_type: string;
error_message: string;
page: string;
};
};
export function track<T extends keyof EventMap>(
event: T,
properties: EventMap[T]
): void {
posthog.capture(event, properties);
}
export function identify(
userId: string,
traits: {
email?: string;
name?: string;
plan?: string;
signup_date?: string;
}
): void {
posthog.identify(userId, traits);
}
export function setGroup(
groupType: string,
groupId: string,
traits?: Record<string, unknown>
): void {
posthog.group(groupType, groupId, traits);
}
export function reset(): void {
posthog.reset();
}
Feature Flags with PostHog
// hooks/use-feature-flag.ts
'use client';
import { useEffect, useState } from 'react';
import { posthog } from '@/lib/posthog';
export function useFeatureFlag(
flagKey: string,
defaultValue: boolean = false
): boolean {
const [enabled, setEnabled] = useState(defaultValue);
useEffect(() => {
// PostHog loads flags async, so we poll
const checkFlag = () => {
const value = posthog.isFeatureEnabled(flagKey);
if (value !== undefined) {
setEnabled(value);
}
};
checkFlag();
posthog.onFeatureFlags(checkFlag);
}, [flagKey]);
return enabled;
}
// Usage in component
function PricingPage() {
const showNewPricing = useFeatureFlag('new-pricing-page');
if (showNewPricing) {
return <NewPricingPage />;
}
return <CurrentPricingPage />;
}
Server-Side Event Tracking
// lib/analytics-server.ts
import { PostHog } from 'posthog-node';
const serverPostHog = new PostHog(process.env.POSTHOG_API_KEY!, {
host: process.env.POSTHOG_HOST || 'https://app.posthog.com',
flushAt: 20,
flushInterval: 10000,
});
export function trackServerEvent(
distinctId: string,
event: string,
properties?: Record<string, unknown>
): void {
serverPostHog.capture({
distinctId,
event,
properties: {
...properties,
$lib: 'posthog-node',
source: 'server',
},
});
}
// Use in API routes, webhooks, server actions
export async function handlePurchase(userId: string, plan: string, amount: number) {
// Business logic...
trackServerEvent(userId, 'purchase_completed', {
plan,
amount,
currency: 'usd',
trial: false,
});
}
// Graceful shutdown
process.on('SIGTERM', () => {
serverPostHog.shutdown();
});
Segment Integration (Multi-destination routing)
// lib/segment.ts
import { AnalyticsBrowser } from '@segment/analytics-next';
export const analytics = AnalyticsBrowser.load({
writeKey: process.env.NEXT_PUBLIC_SEGMENT_WRITE_KEY!,
});
// Segment routes events to multiple destinations:
// - PostHog for product analytics
// - Mixpanel for funnel analysis
// - HubSpot for CRM
// - BigQuery for data warehouse
export async function trackEvent(
event: string,
properties?: Record<string, unknown>
): Promise<void> {
await analytics.track(event, properties);
}
export async function identifyUser(
userId: string,
traits?: Record<string, unknown>
): Promise<void> {
await analytics.identify(userId, traits);
}
export async function trackPageView(
name?: string,
properties?: Record<string, unknown>
): Promise<void> {
await analytics.page(name, properties);
}
GDPR Consent Management
// hooks/use-consent.ts
'use client';
import { useState, useCallback } from 'react';
import { posthog } from '@/lib/posthog';
type ConsentState = {
analytics: boolean;
marketing: boolean;
};
export function useConsent() {
const [consent, setConsent] = useState<ConsentState>(() => {
if (typeof window === 'undefined') return { analytics: false, marketing: false };
const stored = localStorage.getItem('cookie-consent');
return stored ? JSON.parse(stored) : { analytics: false, marketing: false };
});
const updateConsent = useCallback((newConsent: ConsentState) => {
setConsent(newConsent);
localStorage.setItem('cookie-consent', JSON.stringify(newConsent));
if (newConsent.analytics) {
posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
}, []);
const hasConsented = consent.analytics || consent.marketing;
return { consent, updateConsent, hasConsented };
}
Key Metrics to Track
| Metric | Formula | Target |
|---|---|---|
| Activation Rate | Users completing onboarding / Signups | > 40% |
| D1 Retention | Users returning day 1 / Users on day 0 | > 25% |
| D7 Retention | Users returning day 7 / Users on day 0 | > 15% |
| D30 Retention | Users returning day 30 / Users on day 0 | > 10% |
| Conversion Rate | Paid users / Total signups | > 2-5% |
| Feature Adoption | Users using feature / Total active users | Varies |
| Time to Value | Median time from signup to activation | < 5 min |
Analytics Checklist
- Tracking plan documented before implementation
- Event naming follows consistent convention
- User identification set up (identify + alias)
- Page views tracked for all routes
- Core business events tracked (signup, activation, purchase)
- Error events tracked for debugging
- GDPR consent implemented before any tracking
- Events validated in development environment
- Server-side tracking for critical events
- Feature flags integrated for experimentation
Reference Skills
product-analytics- Event tracking architecture patternsgeneric-fullstack-feature-developer- Full-stack integrationseo-analytics-auditor- SEO and analytics audittest-specialist- Testing analytics integration
Supporting file: skills/authentication-patterns/SKILL.md
Authentication Patterns
Overview
This skill covers authentication and authorization implementation across web and mobile applications. It addresses OAuth 2.0 flows (Authorization Code with PKCE, Client Credentials), JWT management (access tokens, refresh tokens, rotation), session management strategies, multi-factor authentication (TOTP, WebAuthn/passkeys), integration with auth libraries (NextAuth/Auth.js v5, Clerk, Supabase Auth, Lucia), SSO protocols (SAML, OIDC), and authorization patterns (RBAC, ABAC).
Use this skill when building login/signup flows, integrating social login providers, implementing MFA, setting up SSO for enterprise customers, designing authorization models, or migrating between auth providers.
Core Principles
- Never roll your own crypto - Use established libraries for password hashing (bcrypt, argon2), JWT signing, and OAuth flows. Custom auth code is the #1 source of security vulnerabilities in web applications.
- Defense in depth - Authentication is not a single check. Layer session validation, CSRF protection, rate limiting, and anomaly detection. Assume every layer can be bypassed individually.
- Tokens are credentials - Access tokens, refresh tokens, and session cookies must be stored securely (httpOnly cookies, encrypted storage), transmitted over HTTPS only, and rotated regularly.
- Least privilege by default - Users and API clients should start with minimal permissions. Elevate access through explicit role assignment, never through implicit trust.
- Plan for account recovery - Password reset, MFA recovery codes, email verification, and account lockout all need designed flows. These are more complex than the happy-path login.
Key Patterns
Pattern 1: NextAuth (Auth.js v5) with OAuth and Database Sessions
When to use: Next.js applications needing social login, email/password, or magic link authentication with server-side session management.
Implementation:
// auth.ts - Auth.js v5 configuration
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
Google({
clientId: process.env.GOOGLE_ID!,
clientSecret: process.env.GOOGLE_SECRET!,
}),
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user?.passwordHash) return null;
const valid = await verifyPassword(
credentials.password as string,
user.passwordHash
);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name };
},
}),
],
session: {
strategy: "database", // Server-side sessions (not JWT)
maxAge: 30 * 24 * 60 * 60, // 30 days
updateAge: 24 * 60 * 60, // Refresh session every 24 hours
},
callbacks: {
async session({ session, user }) {
// Add user role to session
session.user.id = user.id;
session.user.role = user.role;
return session;
},
async signIn({ user, account }) {
// Block sign-in for disabled accounts
if (user.id) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
});
if (dbUser?.disabled) return false;
}
return true;
},
},
pages: {
signIn: "/login",
error: "/auth/error",
verifyRequest: "/auth/verify",
},
});
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
// Middleware for route protection
// middleware.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith("/login") ||
req.nextUrl.pathname.startsWith("/register");
const isDashboard = req.nextUrl.pathname.startsWith("/dashboard");
if (isDashboard && !isLoggedIn) {
return NextResponse.redirect(new URL("/login", req.url));
}
if (isAuthPage && isLoggedIn) {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
return NextResponse.next();
});
export const config = {
matcher: ["/dashboard/:path*", "/login", "/register"],
};
Why: Auth.js v5 handles OAuth complexity (state parameters, PKCE, token exchange), session management, CSRF protection, and provider-specific quirks. Database sessions are more secure than JWT sessions because they can be revoked instantly and don't expose claims to the client.
Pattern 2: JWT Access/Refresh Token Pattern
When to use: API authentication for SPAs, mobile apps, or microservice-to-microservice communication where stateless verification is needed.
Implementation:
// Token generation
import jwt from "jsonwebtoken";
import { randomBytes } from "crypto";
interface TokenPayload {
sub: string; // User ID
email: string;
role: string;
}
interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
const ACCESS_TOKEN_EXPIRY = "15m";
const REFRESH_TOKEN_EXPIRY = "7d";
function generateTokenPair(user: TokenPayload): TokenPair {
const accessToken = jwt.sign(
{ sub: user.sub, email: user.email, role: user.role },
process.env.JWT_SECRET!,
{
expiresIn: ACCESS_TOKEN_EXPIRY,
issuer: "myapp",
audience: "myapp-api",
}
);
// Refresh token is opaque (not JWT) - stored server-side
const refreshToken = randomBytes(64).toString("hex");
return {
accessToken,
refreshToken,
expiresIn: 900, // 15 minutes in seconds
};
}
// Token refresh endpoint
async function refreshTokens(refreshToken: string): Promise<TokenPair> {
// 1. Look up refresh token in database
const stored = await db.refreshToken.findUnique({
where: { token: hashToken(refreshToken) },
include: { user: true },
});
if (!stored || stored.expiresAt < new Date()) {
throw new UnauthorizedError("Invalid or expired refresh token");
}
// 2. Rotate refresh token (invalidate old, create new)
await db.refreshToken.delete({ where: { id: stored.id } });
const newPair = generateTokenPair({
sub: stored.user.id,
email: stored.user.email,
role: stored.user.role,
});
// 3. Store new refresh token
await db.refreshToken.create({
data: {
token: hashToken(newPair.refreshToken),
userId: stored.user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return newPair;
}
// Token verification middleware
function verifyAccessToken(token: string): TokenPayload {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
issuer: "myapp",
audience: "myapp-api",
});
return decoded as TokenPayload;
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
throw new UnauthorizedError("Access token expired");
}
throw new UnauthorizedError("Invalid access token");
}
}
// Secure cookie-based token delivery (for web apps)
function setAuthCookies(res: Response, tokens: TokenPair): void {
// Access token in httpOnly cookie
res.headers.append(
"Set-Cookie",
`access_token=${tokens.accessToken}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${tokens.expiresIn}`
);
// Refresh token in httpOnly cookie with restricted path
res.headers.append(
"Set-Cookie",
`refresh_token=${tokens.refreshToken}; HttpOnly; Secure; SameSite=Strict; Path=/api/auth/refresh; Max-Age=${7 * 24 * 60 * 60}`
);
}
Why: Short-lived access tokens (15 minutes) limit the damage window if a token is stolen. Opaque refresh tokens stored server-side can be revoked immediately (unlike JWTs). Refresh token rotation detects token theft: if a stolen refresh token is used after the legitimate user has already rotated it, the entire token family is invalidated.
Pattern 3: Multi-Factor Authentication (TOTP)
When to use: When you need an additional authentication factor beyond password, especially for admin accounts and sensitive operations.
Implementation:
// MFA setup flow
import { authenticator } from "otplib";
import QRCode from "qrcode";
// Step 1: Generate secret and QR code for user
async function setupMFA(userId: string): Promise<{ qrCodeUrl: string; secret: string }> {
const secret = authenticator.generateSecret();
const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
// Store encrypted secret (not yet verified)
await db.mfaSetup.upsert({
where: { userId },
create: { userId, secret: encrypt(secret), verified: false },
update: { secret: encrypt(secret), verified: false },
});
const otpauth = authenticator.keyuri(user.email, "MyApp", secret);
const qrCodeUrl = await QRCode.toDataURL(otpauth);
return { qrCodeUrl, secret };
}
// Step 2: Verify code to complete setup
async function verifyMFASetup(userId: string, code: string): Promise<string[]> {
const setup = await db.mfaSetup.findUniqueOrThrow({
where: { userId },
});
const secret = decrypt(setup.secret);
const isValid = authenticator.verify({ token: code, secret });
if (!isValid) {
throw new ValidationError("Invalid verification code");
}
// Generate recovery codes
const recoveryCodes = Array.from({ length: 10 }, () =>
randomBytes(4).toString("hex").toUpperCase()
);
// Store hashed recovery codes
await db.$transaction([
db.mfaSetup.update({
where: { userId },
data: { verified: true },
}),
db.user.update({
where: { id: userId },
data: { mfaEnabled: true },
}),
...recoveryCodes.map((code) =>
db.recoveryCode.create({
data: { userId, codeHash: hashCode(code) },
})
),
]);
return recoveryCodes; // Show to user ONCE
}
// Step 3: Verify TOTP during login
async function verifyMFA(userId: string, code: string): Promise<boolean> {
const setup = await db.mfaSetup.findUniqueOrThrow({
where: { userId, verified: true },
});
const secret = decrypt(setup.secret);
// Check TOTP code (allows 1 window of drift)
if (authenticator.verify({ token: code, secret })) {
return true;
}
// Check recovery codes
const recoveryCodes = await db.recoveryCode.findMany({
where: { userId, used: false },
});
for (const rc of recoveryCodes) {
if (await verifyHash(code, rc.codeHash)) {
// Mark recovery code as used (one-time use)
await db.recoveryCode.update({
where: { id: rc.id },
data: { used: true, usedAt: new Date() },
});
return true;
}
}
return false;
}
Why: TOTP-based MFA is widely supported (Google Authenticator, Authy, 1Password), doesn't require SMS (which is vulnerable to SIM swapping), and works offline. Recovery codes provide a safety net when users lose their authenticator device. The encrypted secret and hashed recovery codes protect against database breaches.
Pattern 4: Role-Based Access Control (RBAC)
When to use: When different users need different levels of access to resources.
Implementation:
// Permission definitions
const PERMISSIONS = {
// Projects
"project:read": "View project details",
"project:write": "Edit project settings",
"project:delete": "Delete projects",
"project:manage_members": "Add/remove project members",
// Billing
"billing:read": "View billing information",
"billing:write": "Manage subscriptions and payments",
// Admin
"admin:users": "Manage all users",
"admin:settings": "Manage organization settings",
} as const;
type Permission = keyof typeof PERMISSIONS;
// Role definitions
const ROLES: Record<string, Permission[]> = {
viewer: ["project:read"],
editor: ["project:read", "project:write"],
admin: [
"project:read",
"project:write",
"project:delete",
"project:manage_members",
"billing:read",
"billing:write",
],
owner: Object.keys(PERMISSIONS) as Permission[],
};
// Permission check middleware
function requirePermission(...requiredPermissions: Permission[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const user = req.user;
if (!user) {
return res.status(401).json({ error: "Authentication required" });
}
const userPermissions = ROLES[user.role] ?? [];
const hasAllPermissions = requiredPermissions.every((p) =>
userPermissions.includes(p)
);
if (!hasAllPermissions) {
return res.status(403).json({
error: "Insufficient permissions",
required: requiredPermissions,
current: user.role,
});
}
next();
};
}
// Usage in routes
app.get("/api/projects/:id", requirePermission("project:read"), getProject);
app.put("/api/projects/:id", requirePermission("project:write"), updateProject);
app.delete("/api/projects/:id", requirePermission("project:delete"), deleteProject);
// Component-level permission checks (React)
function usePermission(permission: Permission): boolean {
const { data: session } = useSession();
if (!session?.user?.role) return false;
const permissions = ROLES[session.user.role] ?? [];
return permissions.includes(permission);
}
function ProjectSettings({ project }: { project: Project }) {
const canEdit = usePermission("project:write");
const canDelete = usePermission("project:delete");
return (
<div>
<h2>Settings</h2>
{canEdit ? (
<ProjectForm project={project} />
) : (
<ProjectDetails project={project} />
)}
{canDelete && <DeleteProjectButton projectId={project.id} />}
</div>
);
}
Why: RBAC provides a clear, auditable permission model. Defining permissions as constants with TypeScript ensures compile-time safety. Checking permissions on both server (middleware) and client (UI) provides defense in depth -- the server enforces security, the client provides user experience.
Password Hashing Reference
| Algorithm | Recommended | Cost Factor | Notes |
|---|---|---|---|
| argon2id | Best | Memory: 64MB, Iterations: 3 | Best protection against GPU/ASIC attacks |
| bcrypt | Good | Rounds: 12-14 | Widely supported, proven track record |
| scrypt | Good | N: 2^15, r: 8, p: 1 | Memory-hard, good alternative to argon2 |
| PBKDF2 | Acceptable | Iterations: 600,000+ (SHA-256) | NIST recommended, but not memory-hard |
| MD5/SHA-256 | Never | N/A | Not a password hash -- too fast, no salt |
Anti-Patterns
| Anti-Pattern | Why It's Bad | Better Approach |
|---|---|---|
| Storing passwords in plain text | Single breach exposes all passwords | Use argon2id or bcrypt with per-user salt |
| JWT in localStorage | XSS can steal tokens | httpOnly Secure cookies |
| Long-lived access tokens (days/weeks) | Extended damage window if stolen | 15-minute access tokens + refresh token rotation |
| Checking permissions only on the client | Client-side checks are bypassable | Server-side middleware + client-side UX |
| Custom OAuth implementation | Subtle security bugs (state, PKCE, redirect) | Use established libraries (Auth.js, Passport) |
| SMS-based 2FA as only MFA option | SIM swapping vulnerability | Offer TOTP and WebAuthn alongside SMS |
| No rate limiting on login endpoint | Brute force attacks | Rate limit by IP and username |
| Password reset tokens that don't expire | Token can be used indefinitely | 1-hour expiry, single-use, invalidate on password change |
Checklist
- Passwords hashed with argon2id or bcrypt (never plain text, never MD5/SHA)
- OAuth flows use PKCE for public clients (SPAs, mobile)
- Access tokens are short-lived (< 30 minutes)
- Refresh tokens are rotated on use and stored server-side
- Tokens delivered in httpOnly Secure SameSite cookies (web)
- Login endpoint rate-limited (by IP and username)
- MFA available for all users, enforced for admin roles
- Recovery codes generated during MFA setup
- RBAC permissions checked on server (middleware) and client (UX)
- Password reset tokens expire in 1 hour, single-use
- Account lockout after N failed attempts (with progressive delay)
- Session invalidation on password change
Related Resources
- Skills:
application-security(OWASP auth vulnerabilities),payment-integration(billing auth) - Skills:
email-systems(password reset emails),product-analytics(identify/alias on auth) - Rules:
docs/reference/stacks/fullstack-nextjs-nestjs.md(NestJS auth guards)
Supporting file: skills/product-analytics/SKILL.md
Product Analytics
Overview
This skill covers designing and implementing product analytics systems that provide actionable insights into user behavior. It addresses event taxonomy design, analytics SDK integration with major platforms (PostHog, Amplitude, Mixpanel, Segment), funnel and cohort analysis, A/B testing and feature flags, user journey mapping, GDPR-compliant consent management, and custom metric definitions.
Use this skill when adding analytics to a new product, redesigning an event tracking system, setting up A/B testing infrastructure, implementing consent management, or building custom dashboards for product teams.
Core Principles
- Design the taxonomy before writing code - A well-designed event naming convention and property schema prevents the #1 analytics failure: inconsistent, unqueryable data. Agree on naming conventions first.
- Track actions, not pages - Page views tell you where users went. Events with context tell you what users did and why. Focus on user actions:
project_created,file_uploaded,subscription_upgraded. - Identify before you track - Every event needs a user identity. Anonymous events before signup should be linked to the authenticated identity once the user logs in (alias/merge).
- Consent is mandatory - GDPR and CCPA require explicit user consent before tracking. Build consent management into the analytics layer, not as an afterthought.
- Less is more - Tracking everything creates noise. Track the events that answer specific product questions. You can always add events later; removing noise from existing data is much harder.
Key Patterns
Pattern 1: Event Taxonomy Design
When to use: Before implementing any analytics tracking. This is the foundation everything else builds on.
Implementation:
// Event taxonomy schema
// Convention: object_action (noun_verb in past tense)
// Core event types
type AnalyticsEvent =
// Authentication
| { event: "user_signed_up"; properties: { method: "email" | "google" | "github"; referralSource?: string } }
| { event: "user_logged_in"; properties: { method: "email" | "google" | "github" } }
| { event: "user_logged_out"; properties: Record<string, never> }
// Onboarding
| { event: "onboarding_started"; properties: { variant?: string } }
| { event: "onboarding_step_completed"; properties: { step: number; stepName: string } }
| { event: "onboarding_completed"; properties: { durationSeconds: number } }
| { event: "onboarding_skipped"; properties: { lastStep: number } }
// Core product actions
| { event: "project_created"; properties: { template?: string; source: "dashboard" | "onboarding" | "api" } }
| { event: "project_deleted"; properties: { projectAge: number; itemCount: number } }
| { event: "file_uploaded"; properties: { fileType: string; fileSizeBytes: number; source: "drag_drop" | "file_picker" | "api" } }
// Subscription
| { event: "subscription_started"; properties: { plan: string; billingCycle: "monthly" | "annual"; amount: number } }
| { event: "subscription_upgraded"; properties: { fromPlan: string; toPlan: string } }
| { event: "subscription_cancelled"; properties: { reason?: string; plan: string; tenureDays: number } }
// Feature engagement
| { event: "feature_used"; properties: { feature: string; context: string } }
| { event: "search_performed"; properties: { query: string; resultCount: number; source: string } }
| { event: "export_completed"; properties: { format: "csv" | "pdf" | "json"; itemCount: number } };
// Type-safe tracking function
function track<T extends AnalyticsEvent>(
event: T["event"],
properties: T["properties"]
): void {
// Implementation below
}
// Usage - fully typed, autocomplete works
track("project_created", {
template: "blank",
source: "dashboard",
});
Why: A typed event taxonomy prevents typos (user_signedup vs user_signed_up), ensures required properties are always present, and makes the tracking plan self-documenting. The object_action convention groups related events together in analytics dashboards.
Pattern 2: Analytics Client with Consent Management
When to use: Every product that tracks user behavior, which is every product.
Implementation:
// analytics.ts - Unified analytics client
import posthog from "posthog-js";
type ConsentStatus = "granted" | "denied" | "pending";
interface AnalyticsConfig {
posthogKey: string;
posthogHost?: string;
}
class Analytics {
private initialized = false;
private consent: ConsentStatus = "pending";
private queuedEvents: Array<{ event: string; properties: Record<string, unknown> }> = [];
init(config: AnalyticsConfig) {
posthog.init(config.posthogKey, {
api_host: config.posthogHost ?? "https://us.i.posthog.com",
persistence: "localStorage+cookie",
autocapture: false, // Explicit tracking only
capture_pageview: false, // Manual pageview tracking
capture_pageleave: true,
// Respect Do Not Track
respect_dnt: true,
// Cookie-less mode until consent
persistence: this.consent === "granted" ? "localStorage+cookie" : "memory",
});
this.initialized = true;
}
setConsent(status: ConsentStatus) {
this.consent = status;
if (status === "granted") {
posthog.opt_in_capturing();
// Flush queued events
for (const event of this.queuedEvents) {
this.trackInternal(event.event, event.properties);
}
this.queuedEvents = [];
} else if (status === "denied") {
posthog.opt_out_capturing();
this.queuedEvents = [];
}
}
identify(userId: string, traits?: Record<string, unknown>) {
if (this.consent !== "granted") return;
posthog.identify(userId, traits);
}
// Alias anonymous ID to authenticated ID (for signup flow)
alias(newId: string) {
if (this.consent !== "granted") return;
posthog.alias(newId);
}
track(event: string, properties?: Record<string, unknown>) {
if (this.consent === "denied") return;
const enrichedProperties = {
...properties,
timestamp: new Date().toISOString(),
url: typeof window !== "undefined" ? window.location.href : undefined,
referrer: typeof document !== "undefined" ? document.referrer : undefined,
};
if (this.consent === "pending") {
this.queuedEvents.push({ event, properties: enrichedProperties });
return;
}
this.trackInternal(event, enrichedProperties);
}
private trackInternal(event: string, properties: Record<string, unknown>) {
if (!this.initialized) return;
posthog.capture(event, properties);
}
page(name?: string, properties?: Record<string, unknown>) {
this.track("$pageview", { pageName: name, ...properties });
}
reset() {
posthog.reset();
}
}
export const analytics = new Analytics();
// React consent banner component
function ConsentBanner() {
const [showBanner, setShowBanner] = useState(() => {
return localStorage.getItem("analytics_consent") === null;
});
const handleConsent = (granted: boolean) => {
const status = granted ? "granted" : "denied";
localStorage.setItem("analytics_consent", status);
analytics.setConsent(status);
setShowBanner(false);
};
if (!showBanner) return null;
return (
<div role="dialog" aria-label="Cookie consent" className="consent-banner">
<p>We use analytics to improve our product. No personal data is sold.</p>
<div className="consent-actions">
<button onClick={() => handleConsent(false)}>Decline</button>
<button onClick={() => handleConsent(true)}>Accept</button>
</div>
</div>
);
}
Why: Consent-first analytics is legally required (GDPR, CCPA) and builds user trust. The queue pattern ensures no events are lost if the user grants consent after performing actions. Explicit tracking (no autocapture) keeps data clean and intentional.
Pattern 3: Funnel Analysis Implementation
When to use: Measuring conversion through multi-step flows (signup, onboarding, checkout).
Implementation:
// Track each funnel step explicitly
const ONBOARDING_FUNNEL = [
"onboarding_started",
"onboarding_step_completed:profile",
"onboarding_step_completed:workspace",
"onboarding_step_completed:invite",
"onboarding_completed",
] as const;
// Instrument each step
function OnboardingFlow() {
const [currentStep, setCurrentStep] = useState(0);
useEffect(() => {
analytics.track("onboarding_started", { variant: "v2" });
}, []);
const completeStep = (stepName: string) => {
analytics.track("onboarding_step_completed", {
step: currentStep,
stepName,
timeOnStepSeconds: getTimeOnStep(),
});
setCurrentStep((prev) => prev + 1);
};
const skip = () => {
analytics.track("onboarding_skipped", {
lastStep: currentStep,
lastStepName: steps[currentStep].name,
});
router.push("/dashboard");
};
const complete = () => {
analytics.track("onboarding_completed", {
durationSeconds: getTotalDuration(),
stepsCompleted: currentStep + 1,
stepsSkipped: steps.length - currentStep - 1,
});
router.push("/dashboard");
};
// ...render steps
}
// Checkout funnel tracking
function useCheckoutFunnel() {
const trackStep = (step: string, properties?: Record<string, unknown>) => {
analytics.track(`checkout_${step}`, {
...properties,
cartValue: getCartTotal(),
itemCount: getCartItemCount(),
timestamp: Date.now(),
});
};
return {
viewCart: () => trackStep("cart_viewed"),
startCheckout: () => trackStep("started"),
addShipping: (method: string) => trackStep("shipping_added", { method }),
addPayment: (type: string) => trackStep("payment_added", { type }),
complete: (orderId: string) => trackStep("completed", { orderId }),
abandon: (step: string) => trackStep("abandoned", { lastStep: step }),
};
}
Why: Funnel analysis reveals where users drop off. Tracking each step with context (time spent, variant, cart value) enables segmented analysis: "Users from Google Ads drop off at the shipping step 3x more than organic users." This insight drives targeted optimization.
Pattern 4: Feature Flag-Driven A/B Testing
When to use: Testing product changes with statistical rigor before rolling out to all users.
Implementation:
// PostHog feature flags for A/B testing
import posthog from "posthog-js";
// Check feature flag with typed variants
function useExperiment<T extends string>(
flagKey: string,
variants: T[]
): T | "control" | undefined {
const [variant, setVariant] = useState<T | "control" | undefined>();
useEffect(() => {
posthog.onFeatureFlags(() => {
const value = posthog.getFeatureFlag(flagKey);
if (value === false || value === undefined) {
setVariant("control");
} else if (typeof value === "string" && variants.includes(value as T)) {
setVariant(value as T);
} else {
setVariant("control");
}
// Track experiment exposure (for accurate analysis)
analytics.track("$experiment_started", {
experiment: flagKey,
variant: value ?? "control",
});
});
}, [flagKey]);
return variant;
}
// Usage in component
function PricingPage() {
const variant = useExperiment("pricing-page-redesign", ["new-layout", "social-proof"]);
if (!variant) return <LoadingSkeleton />;
switch (variant) {
case "new-layout":
return <PricingNewLayout />;
case "social-proof":
return <PricingSocialProof />;
case "control":
default:
return <PricingControl />;
}
}
Why: A/B testing removes opinion from product decisions. Feature flags enable gradual rollout, instant rollback, and segment-specific targeting. Tracking exposure events separately from conversion events ensures accurate statistical analysis (intent-to-treat analysis).
Event Naming Convention Reference
| Convention | Example | When to Use |
|---|---|---|
object_action | project_created, file_uploaded | Standard product events |
$system_event | $pageview, $identify | System/platform events |
experiment_* | $experiment_started | A/B test tracking |
feature_used | feature_used { feature: "export" } | Generic feature engagement |
Anti-Patterns
| Anti-Pattern | Why It's Bad | Better Approach |
|---|---|---|
| Autocapture everything | Noisy data, hard to query, privacy risk | Explicit event tracking with defined taxonomy |
| Inconsistent event names | userSignedUp vs user_signed_up vs signup | Enforce naming convention with TypeScript types |
| Tracking without consent | GDPR/CCPA violation, fines up to 4% revenue | Consent banner with queue pattern |
| No user identification | Cannot do cohort analysis or retention | Identify users on login, alias on signup |
| Tracking PII in event properties | Privacy violation, data retention issues | Use user IDs, not emails/names in event properties |
| One massive "user_action" event | Cannot create meaningful funnels | Specific event per meaningful action |
| Not tracking negative outcomes | Only see what works, not what fails | Track errors, abandonment, rage clicks |
Checklist
- Event taxonomy documented and typed (TypeScript interface)
- Naming convention enforced (
object_actionpattern) - Analytics client initialized with consent management
- User identification on login, alias on signup
- Funnel steps tracked with contextual properties
- Feature flags set up for A/B testing
- Experiment exposure events tracked separately from conversions
- GDPR consent banner implemented with queue pattern
- No PII in event properties (emails, full names, IPs)
- Key funnels visualized in analytics dashboard
- Retention cohorts configured (Day 1, Day 7, Day 30)
- Analytics events validated in development (debug mode)
Related Resources
- Skills:
growth-engineering(A/B testing infrastructure),authentication-patterns(identify/alias flow) - Skills:
accessibility-a11y(accessible consent banners) - Rules:
docs/reference/stacks/react-typescript.md(React hooks for analytics)
Common questions
How do I install Growth engineering skill in Cursor, Claude Code, or Codex?
Run npx skills add travisjneuman/.claude --skill growth-engineering in the project where you want it, then ask your agent for the skill by name. The --skill flag installs only Growth engineering skill, not every skill in the repository.
Where does Growth engineering skill come from and what license is it under?
Growth engineering skill comes from the travisjneuman/.claude repository on GitHub. That repository has 94 GitHub stars. The skill is published under the MIT license.
Prefer plain text? Read the Growth engineering skill guide as markdown.
Related skills
More from travisjneuman