Growth engineering skill

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.
Install-only

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.

Terminal
$ npx skills add travisjneuman/.claude --skill growth-engineering

Skill instructions

The instruction file for this skill. The skill also includes other files you need to install to use it.

SKILL.md

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

StageMetricExample
Sign upRegistration completeUser creates account
SetupProfile completeFills required fields
Aha momentCore value experiencedCreates first project
HabitRepeated engagement3 sessions in first week
RevenueConversion to paidSubscribes 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

MetricFormulaTarget
Activation rateActivated / Signed up> 40%
Trial-to-paidPaid / Trial started> 15%
Net revenue retention(Start MRR + Expansion - Contraction - Churn) / Start MRR> 110%
Viral coefficientInvites sent * Conversion rate> 0.5
Time to valueMedian time from signup to aha moment< 5 min
DAU/MAU ratioDaily active / Monthly active> 20%

Experimentation Platforms

PlatformTypeBest For
PostHogSelf-hosted/cloudFull-stack, open source
LaunchDarklyCloudFeature flags at scale
StatsigCloudAuto-stats, warehouse-native
GrowthbookSelf-hosted/cloudOpen source, Bayesian stats
OptimizelyCloudEnterprise, 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:

  1. Design the tracking plan first: Before writing code, define the event taxonomy, naming conventions, and key metrics. A tracking plan document prevents inconsistent events.
  2. Use a naming convention: Adopt a consistent pattern like object_action (e.g., page_viewed, button_clicked, purchase_completed).
  3. Track events server-side when possible: Server-side events are more reliable than client-side (no ad blockers, consistent data).
  4. Respect user privacy: Always implement consent before tracking. Anonymize where possible. Never track PII in event properties without consent.
  5. 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

MetricFormulaTarget
Activation RateUsers completing onboarding / Signups> 40%
D1 RetentionUsers returning day 1 / Users on day 0> 25%
D7 RetentionUsers returning day 7 / Users on day 0> 15%
D30 RetentionUsers returning day 30 / Users on day 0> 10%
Conversion RatePaid users / Total signups> 2-5%
Feature AdoptionUsers using feature / Total active usersVaries
Time to ValueMedian 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 patterns
  • generic-fullstack-feature-developer - Full-stack integration
  • seo-analytics-auditor - SEO and analytics audit
  • test-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

  1. 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.
  2. 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.
  3. 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.
  4. Least privilege by default - Users and API clients should start with minimal permissions. Elevate access through explicit role assignment, never through implicit trust.
  5. 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

AlgorithmRecommendedCost FactorNotes
argon2idBestMemory: 64MB, Iterations: 3Best protection against GPU/ASIC attacks
bcryptGoodRounds: 12-14Widely supported, proven track record
scryptGoodN: 2^15, r: 8, p: 1Memory-hard, good alternative to argon2
PBKDF2AcceptableIterations: 600,000+ (SHA-256)NIST recommended, but not memory-hard
MD5/SHA-256NeverN/ANot a password hash -- too fast, no salt

Anti-Patterns

Anti-PatternWhy It's BadBetter Approach
Storing passwords in plain textSingle breach exposes all passwordsUse argon2id or bcrypt with per-user salt
JWT in localStorageXSS can steal tokenshttpOnly Secure cookies
Long-lived access tokens (days/weeks)Extended damage window if stolen15-minute access tokens + refresh token rotation
Checking permissions only on the clientClient-side checks are bypassableServer-side middleware + client-side UX
Custom OAuth implementationSubtle security bugs (state, PKCE, redirect)Use established libraries (Auth.js, Passport)
SMS-based 2FA as only MFA optionSIM swapping vulnerabilityOffer TOTP and WebAuthn alongside SMS
No rate limiting on login endpointBrute force attacksRate limit by IP and username
Password reset tokens that don't expireToken can be used indefinitely1-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

  1. 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.
  2. 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.
  3. 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).
  4. Consent is mandatory - GDPR and CCPA require explicit user consent before tracking. Build consent management into the analytics layer, not as an afterthought.
  5. 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

ConventionExampleWhen to Use
object_actionproject_created, file_uploadedStandard product events
$system_event$pageview, $identifySystem/platform events
experiment_*$experiment_startedA/B test tracking
feature_usedfeature_used { feature: "export" }Generic feature engagement

Anti-Patterns

Anti-PatternWhy It's BadBetter Approach
Autocapture everythingNoisy data, hard to query, privacy riskExplicit event tracking with defined taxonomy
Inconsistent event namesuserSignedUp vs user_signed_up vs signupEnforce naming convention with TypeScript types
Tracking without consentGDPR/CCPA violation, fines up to 4% revenueConsent banner with queue pattern
No user identificationCannot do cohort analysis or retentionIdentify users on login, alias on signup
Tracking PII in event propertiesPrivacy violation, data retention issuesUse user IDs, not emails/names in event properties
One massive "user_action" eventCannot create meaningful funnelsSpecific event per meaningful action
Not tracking negative outcomesOnly see what works, not what failsTrack errors, abandonment, rage clicks

Checklist

  • Event taxonomy documented and typed (TypeScript interface)
  • Naming convention enforced (object_action pattern)
  • 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)

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.