IAMUVIN

Cybersecurity & Ethical Hacking

OWASP Top 10 for Next.js Developers: Practical Security Guide

Uvin Vindula·March 10, 2025·13 min read

Last updated: April 14, 2026

Share

TL;DR

The OWASP Top 10 is not optional reading for Next.js developers — it is the baseline. Every vulnerability on the list has a direct equivalent in the Next.js ecosystem, from broken access control in Server Actions to SSRF through server-side fetches. This guide walks through all ten risks with real vulnerable code, the fixed version, and the middleware configuration that prevents each class of attack. I apply the OWASP Top 10 to every web project I build and every security audit I deliver. If you ship a Next.js app without addressing these, you are shipping a liability.


Why OWASP Top 10 Matters for Next.js

Most Next.js tutorials skip security entirely. They show you how to fetch data, render pages, and deploy to Vercel. They don't show you what happens when someone sends a crafted request to your Server Action, bypasses your middleware auth check, or injects a script through a search parameter you forgot to sanitize.

The OWASP Top 10 for Next.js is the list I check before every deployment. I run blockchain security audits where a single missed vulnerability can drain millions from a smart contract. Web applications are no different in principle — the attack surface is just broader. The 2021 OWASP Top 10 remains the current standard, and every item on it maps directly to something you are probably doing wrong in your Next.js codebase right now.

Let's fix that. Every section below gives you the vulnerability, the exploit, and the code to stop it.


A01: Broken Access Control

The number one vulnerability on the OWASP list, and the one I find most often in Next.js audits. The pattern is always the same: a developer protects the page with middleware but leaves the API route or Server Action wide open.

Vulnerable code — Server Action without auth check:

typescript
// app/admin/actions.ts
"use server";

export async function deleteUser(userId: string) {
  // No auth check. Anyone who knows this action exists can call it.
  await db.user.delete({ where: { id: userId } });
  return { success: true };
}

Fixed code — auth and role verification on every action:

typescript
// app/admin/actions.ts
"use server";

import { auth } from "@/lib/auth";

export async function deleteUser(userId: string) {
  const session = await auth();

  if (!session?.user) {
    throw new Error("Unauthorized");
  }

  if (session.user.role !== "ADMIN") {
    throw new Error("Forbidden: insufficient permissions");
  }

  // Prevent self-deletion
  if (session.user.id === userId) {
    throw new Error("Cannot delete your own account");
  }

  await db.user.delete({ where: { id: userId } });
  revalidatePath("/admin/users");
  return { success: true };
}

Rule: Every Server Action and Route Handler checks authentication AND authorization. Middleware is a convenience layer, not a security boundary. The action itself is the last line of defense.


A02: Cryptographic Failures

Exposing sensitive data through inadequate encryption. In Next.js, this usually means leaking secrets to the client bundle, storing passwords in plain text, or transmitting tokens without HTTPS.

Vulnerable code — secret leaked to client:

typescript
// app/dashboard/page.tsx
// This is a Client Component — NEXT_PUBLIC_ env vars AND any
// imported module code ships to the browser
"use client";

const API_SECRET = process.env.API_SECRET; // undefined on client, but the
// developer might hardcode it as a workaround:
const API_SECRET_HARDCODED = "sk-live-abc123"; // Now in your JS bundle

Fixed code — secrets stay on the server:

typescript
// app/api/data/route.ts (Server-only Route Handler)
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  const session = await auth();
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // process.env.API_SECRET is available here — never leaves the server
  const data = await fetch("https://api.example.com/data", {
    headers: { Authorization: `Bearer ${process.env.API_SECRET}` },
  });

  return NextResponse.json(await data.json());
}

Password hashing — always bcrypt or argon2:

typescript
// lib/auth/password.ts
import bcrypt from "bcryptjs";

const SALT_ROUNDS = 12;

export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

export async function verifyPassword(
  password: string,
  hash: string
): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

Rule: Never use NEXT_PUBLIC_ prefix for secrets. Never hash passwords with MD5 or SHA-1. Never store tokens in localStorage — use httpOnly cookies.


A03: Injection

SQL injection, XSS, and command injection. Next.js Server Components mitigate some XSS by default because they don't ship raw HTML to the client, but the moment you use dangerouslySetInnerHTML or interpolate user input into a query, you are exposed.

Vulnerable code — SQL injection via string interpolation:

typescript
// app/api/users/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const name = searchParams.get("name");

  // NEVER do this. An attacker sends: ?name=' OR '1'='1
  const users = await db.$queryRawUnsafe(
    `SELECT * FROM users WHERE name = '${name}'`
  );

  return NextResponse.json(users);
}

Fixed code — parameterized queries:

typescript
// app/api/users/route.ts
import { z } from "zod";

const searchSchema = z.object({
  name: z.string().min(1).max(100),
});

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const parsed = searchSchema.safeParse({
    name: searchParams.get("name"),
  });

  if (!parsed.success) {
    return NextResponse.json(
      { error: "Invalid search parameters" },
      { status: 400 }
    );
  }

  // Prisma parameterizes automatically. $queryRaw uses tagged templates.
  const users = await db.user.findMany({
    where: { name: { contains: parsed.data.name } },
    select: { id: true, name: true, email: true },
  });

  return NextResponse.json(users);
}

XSS prevention — never trust user HTML:

typescript
// BAD: dangerouslySetInnerHTML with unsanitized input
<div dangerouslySetInnerHTML={{ __html: userComment }} />

// GOOD: sanitize with DOMPurify if you must render HTML
import DOMPurify from "isomorphic-dompurify";

<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userComment) }} />

// BEST: don't render user HTML at all. Use markdown with a safe renderer.

Rule: Validate every input with Zod. Use Prisma's query builder instead of raw SQL. If you must use $queryRaw, use tagged template literals — never $queryRawUnsafe.


A04: Insecure Design

Security flaws baked into the architecture, not just the code. The most common Next.js insecure design pattern: trusting client-side validation as the only validation.

Vulnerable code — client-only validation:

typescript
// components/TransferForm.tsx
"use client";

export function TransferForm() {
  const handleSubmit = (formData: FormData) => {
    const amount = Number(formData.get("amount"));
    if (amount > 10000) {
      alert("Max transfer is $10,000"); // Attacker skips this entirely
      return;
    }
    transferAction(formData);
  };
  // ...
}

Fixed code — server-side validation is the real boundary:

typescript
// app/transfer/actions.ts
"use server";

import { z } from "zod";
import { auth } from "@/lib/auth";

const transferSchema = z.object({
  amount: z.number().positive().max(10000),
  recipientId: z.string().uuid(),
});

export async function transferAction(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error("Unauthorized");

  const parsed = transferSchema.safeParse({
    amount: Number(formData.get("amount")),
    recipientId: formData.get("recipientId"),
  });

  if (!parsed.success) {
    return { error: "Invalid transfer data", details: parsed.error.flatten() };
  }

  // Rate limiting: max 5 transfers per hour
  const recentTransfers = await db.transfer.count({
    where: {
      senderId: session.user.id,
      createdAt: { gte: new Date(Date.now() - 3600000) },
    },
  });

  if (recentTransfers >= 5) {
    return { error: "Rate limit exceeded. Try again later." };
  }

  await db.transfer.create({
    data: {
      senderId: session.user.id,
      recipientId: parsed.data.recipientId,
      amount: parsed.data.amount,
    },
  });

  return { success: true };
}

Rule: Client validation is UX. Server validation is security. Every form needs both, and they must agree.


A05: Security Misconfiguration

Default settings, verbose error messages, and missing security headers. This is where Next.js middleware earns its keep.

The security headers middleware I use on every project:

typescript
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Prevent clickjacking
  response.headers.set("X-Frame-Options", "DENY");

  // Stop MIME type sniffing
  response.headers.set("X-Content-Type-Options", "nosniff");

  // Force HTTPS for 1 year including subdomains
  response.headers.set(
    "Strict-Transport-Security",
    "max-age=31536000; includeSubDomains; preload"
  );

  // Content Security Policy — lock down script and style sources
  response.headers.set(
    "Content-Security-Policy",
    [
      "default-src 'self'",
      "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "font-src 'self' https://fonts.gstatic.com",
      "connect-src 'self' https://api.example.com",
      "frame-ancestors 'none'",
      "base-uri 'self'",
      "form-action 'self'",
    ].join("; ")
  );

  // Control referrer information
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

  // Disable browser features you don't use
  response.headers.set(
    "Permissions-Policy",
    "camera=(), microphone=(), geolocation=(), interest-cohort=()"
  );

  return response;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

next.config.ts hardening:

typescript
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Disable x-powered-by header (reveals tech stack)
  poweredByHeader: false,

  // Strict mode catches bugs early
  reactStrictMode: true,

  // Restrict image domains
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "your-cdn.com" },
    ],
  },

  // Security headers as fallback (middleware is primary)
  headers: async () => [
    {
      source: "/(.*)",
      headers: [
        { key: "X-DNS-Prefetch-Control", value: "on" },
        { key: "X-Frame-Options", value: "DENY" },
      ],
    },
  ],
};

export default nextConfig;

Rule: poweredByHeader: false is line one of any Next.js config. Middleware sets security headers. Verbose errors never reach production — use custom error pages.


A06: Vulnerable and Outdated Components

Using packages with known CVEs. The npm ecosystem moves fast, and vulnerabilities are discovered daily.

Audit your dependencies regularly:

bash
# Check for known vulnerabilities
npm audit

# Fix automatically where possible
npm audit fix

# For deeper analysis, use Snyk
npx snyk test

Lock your dependency versions in production:

json
// package.json — pin exact versions for critical packages
{
  "dependencies": {
    "next": "16.0.4",
    "react": "19.1.0",
    "@prisma/client": "6.5.0"
  }
}

Automate with GitHub Actions:

yaml
# .github/workflows/security-audit.yml
name: Security Audit
on:
  schedule:
    - cron: "0 9 * * 1" # Every Monday at 9am
  push:
    branches: [main]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
      - run: npm ci
      - run: npm audit --audit-level=high
      - name: Snyk Security Check
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

Rule: npm audit runs on every CI pipeline. Dependabot or Renovate is enabled on every repo. No package older than six months without a documented reason.


A07: Identification and Authentication Failures

Weak passwords, missing MFA, session tokens that never expire. Next.js apps using NextAuth.js (Auth.js) or Supabase Auth get a lot of this right by default, but there are still gaps.

Vulnerable code — no session expiry, no rotation:

typescript
// auth.ts — insecure session config
export const authOptions = {
  session: {
    strategy: "jwt",
    // No maxAge = session lives forever
  },
  // No CSRF protection configured
};

Fixed code — hardened auth configuration:

typescript
// auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(db),
  session: {
    strategy: "jwt",
    maxAge: 24 * 60 * 60, // 24 hours — then re-authenticate
    updateAge: 60 * 60,   // Rotate session token every hour
  },
  callbacks: {
    async jwt({ token, user, trigger }) {
      if (user) {
        token.role = user.role;
        token.id = user.id;
      }

      // Force re-auth if user was deactivated
      if (trigger === "update") {
        const dbUser = await db.user.findUnique({
          where: { id: token.id as string },
        });
        if (!dbUser || !dbUser.active) {
          throw new Error("Account deactivated");
        }
      }

      return token;
    },
    async session({ session, token }) {
      session.user.role = token.role as string;
      session.user.id = token.id as string;
      return session;
    },
  },
  pages: {
    signIn: "/auth/sign-in",
    error: "/auth/error",
  },
});

Account lockout after failed attempts:

typescript
// lib/auth/rate-limit.ts
const LOGIN_ATTEMPTS = new Map<string, { count: number; lastAttempt: number }>();
const MAX_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes

export function checkLoginRateLimit(email: string): {
  allowed: boolean;
  retryAfter?: number;
} {
  const record = LOGIN_ATTEMPTS.get(email);
  const now = Date.now();

  if (!record) {
    LOGIN_ATTEMPTS.set(email, { count: 1, lastAttempt: now });
    return { allowed: true };
  }

  if (now - record.lastAttempt > LOCKOUT_DURATION) {
    LOGIN_ATTEMPTS.set(email, { count: 1, lastAttempt: now });
    return { allowed: true };
  }

  if (record.count >= MAX_ATTEMPTS) {
    const retryAfter = LOCKOUT_DURATION - (now - record.lastAttempt);
    return { allowed: false, retryAfter };
  }

  record.count++;
  record.lastAttempt = now;
  return { allowed: true };
}

Rule: Sessions expire. Passwords require minimum 12 characters. Failed login attempts trigger lockout. For production, use Redis instead of in-memory maps for rate limiting.


A08: Software and Data Integrity Failures

Trusting data from external sources without verification. In Next.js, this includes unverified webhook payloads, untrusted CDN scripts, and deserialization of user-supplied JSON.

Vulnerable code — trusting a webhook payload blindly:

typescript
// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const body = await request.json();
  // No signature verification — anyone can forge this request
  await processPayment(body.data.object);
  return NextResponse.json({ received: true });
}

Fixed code — verify webhook signatures:

typescript
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  const body = await request.text();
  const headersList = await headers();
  const signature = headersList.get("stripe-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error("Webhook signature verification failed:", err);
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  // Now we know the event is genuinely from Stripe
  switch (event.type) {
    case "payment_intent.succeeded":
      await processPayment(event.data.object);
      break;
    case "payment_intent.payment_failed":
      await handleFailedPayment(event.data.object);
      break;
  }

  return NextResponse.json({ received: true });
}

SRI hashes for external scripts:

html
<!-- Subresource Integrity prevents tampered CDN scripts -->
<script
  src="https://cdn.example.com/analytics.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxAh6VgnUnMSIklDHs7a4DQoT4A0r6"
  crossorigin="anonymous"
></script>

Rule: Verify every webhook signature. Use SRI hashes for external scripts. Never deserialize user-supplied data without schema validation.


A09: Security Logging and Monitoring Failures

If you cannot detect an attack, you cannot respond to it. Most Next.js apps have zero security logging.

Implement structured security logging:

typescript
// lib/security/logger.ts
type SecurityEvent = {
  type:
    | "AUTH_SUCCESS"
    | "AUTH_FAILURE"
    | "ACCESS_DENIED"
    | "RATE_LIMIT"
    | "SUSPICIOUS_INPUT"
    | "ADMIN_ACTION";
  userId?: string;
  ip: string;
  path: string;
  details: Record<string, unknown>;
  timestamp: string;
};

export function logSecurityEvent(event: SecurityEvent): void {
  // Structured JSON for log aggregation (Datadog, Logflare, etc.)
  console.log(
    JSON.stringify({
      level: "SECURITY",
      ...event,
      timestamp: event.timestamp || new Date().toISOString(),
    })
  );
}

// Usage in auth flow
export function onAuthFailure(email: string, ip: string, reason: string) {
  logSecurityEvent({
    type: "AUTH_FAILURE",
    ip,
    path: "/auth/sign-in",
    details: { email, reason },
    timestamp: new Date().toISOString(),
  });
}

Log security events in middleware:

typescript
// middleware.ts (add to existing middleware)
import { logSecurityEvent } from "@/lib/security/logger";

// Inside the middleware function, after auth check:
if (!session && protectedPaths.some((p) => request.nextUrl.pathname.startsWith(p))) {
  logSecurityEvent({
    type: "ACCESS_DENIED",
    ip: request.headers.get("x-forwarded-for") || "unknown",
    path: request.nextUrl.pathname,
    details: { reason: "No session on protected route" },
    timestamp: new Date().toISOString(),
  });

  return NextResponse.redirect(new URL("/auth/sign-in", request.url));
}

Rule: Log every auth failure, every access denial, every rate limit hit. Ship logs to a centralized platform. Set up alerts for anomalies — five failed logins from the same IP in a minute is not normal.


A10: Server-Side Request Forgery (SSRF)

The attacker tricks your server into making requests to internal resources. In Next.js, this happens when you fetch a URL provided by the user without validating it — especially in Server Components and Route Handlers.

Vulnerable code — unvalidated URL fetch:

typescript
// app/api/preview/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const url = searchParams.get("url");

  // Attacker sends: ?url=http://169.254.169.254/latest/meta-data/
  // Your server fetches AWS metadata credentials
  const response = await fetch(url!);
  const html = await response.text();

  return NextResponse.json({ preview: html });
}

Fixed code — URL validation and allowlist:

typescript
// lib/security/url-validator.ts
const ALLOWED_PROTOCOLS = ["https:"];
const BLOCKED_HOSTS = [
  "localhost",
  "127.0.0.1",
  "0.0.0.0",
  "169.254.169.254",       // AWS metadata
  "metadata.google.internal", // GCP metadata
  "100.100.100.200",        // Azure metadata
];
const BLOCKED_CIDRS = ["10.", "172.16.", "172.17.", "172.18.", "192.168."];

export function validateExternalUrl(input: string): {
  valid: boolean;
  url?: URL;
  error?: string;
} {
  let url: URL;

  try {
    url = new URL(input);
  } catch {
    return { valid: false, error: "Invalid URL format" };
  }

  if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
    return { valid: false, error: "Only HTTPS URLs are allowed" };
  }

  if (BLOCKED_HOSTS.includes(url.hostname)) {
    return { valid: false, error: "This host is not allowed" };
  }

  if (BLOCKED_CIDRS.some((cidr) => url.hostname.startsWith(cidr))) {
    return { valid: false, error: "Private IP ranges are not allowed" };
  }

  return { valid: true, url };
}

// app/api/preview/route.ts
import { validateExternalUrl } from "@/lib/security/url-validator";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const rawUrl = searchParams.get("url");

  if (!rawUrl) {
    return NextResponse.json({ error: "URL is required" }, { status: 400 });
  }

  const validation = validateExternalUrl(rawUrl);
  if (!validation.valid) {
    return NextResponse.json({ error: validation.error }, { status: 400 });
  }

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);

  try {
    const response = await fetch(validation.url!.toString(), {
      signal: controller.signal,
      redirect: "error", // Don't follow redirects to internal hosts
    });

    const html = await response.text();
    return NextResponse.json({ preview: html.slice(0, 5000) });
  } catch {
    return NextResponse.json({ error: "Failed to fetch URL" }, { status: 502 });
  } finally {
    clearTimeout(timeout);
  }
}

Rule: Never fetch a user-supplied URL without validation. Block private IP ranges, cloud metadata endpoints, and non-HTTPS protocols. Set timeouts and disable redirect following.


Complete Security Headers Configuration

Here is the production-ready middleware I ship with every Next.js project. Copy this into your middleware.ts and adjust the CSP directives for your specific external resources.

typescript
// middleware.ts — production security middleware
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const CSP_DIRECTIVES = {
  "default-src": ["'self'"],
  "script-src": ["'self'"],
  "style-src": ["'self'", "'unsafe-inline'"],
  "img-src": ["'self'", "data:", "https:"],
  "font-src": ["'self'", "https://fonts.gstatic.com"],
  "connect-src": ["'self'"],
  "frame-ancestors": ["'none'"],
  "base-uri": ["'self'"],
  "form-action": ["'self'"],
  "upgrade-insecure-requests": [],
};

function buildCSP(): string {
  return Object.entries(CSP_DIRECTIVES)
    .map(([key, values]) =>
      values.length > 0 ? `${key} ${values.join(" ")}` : key
    )
    .join("; ");
}

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  response.headers.set("X-Frame-Options", "DENY");
  response.headers.set("X-Content-Type-Options", "nosniff");
  response.headers.set(
    "Strict-Transport-Security",
    "max-age=31536000; includeSubDomains; preload"
  );
  response.headers.set("Content-Security-Policy", buildCSP());
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
  response.headers.set(
    "Permissions-Policy",
    "camera=(), microphone=(), geolocation=(), interest-cohort=()"
  );
  response.headers.set("X-DNS-Prefetch-Control", "on");

  return response;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

You can verify your headers are working by running curl -I https://yourdomain.com or using securityheaders.com. An A+ rating is the target.


Key Takeaways

  1. Server Actions are not secure by default. Every action needs its own auth and authorization check. Middleware is a convenience, not a guarantee.
  1. Validate on the server. Always. Client-side validation is UX. Zod schemas on Server Actions and Route Handlers are security.
  1. Security headers go in middleware. CSP, HSTS, X-Frame-Options, Permissions-Policy. Copy the middleware above. Adjust the CSP directives. Deploy.
  1. Parameterized queries only. Use Prisma's query builder. If you use $queryRaw, use tagged template literals. Never interpolate user input into SQL.
  1. Verify every webhook. Stripe, GitHub, Twilio — they all provide signature verification. Use it or get forged.
  1. Log security events. Auth failures, access denials, rate limit hits. If you can't see the attack, you can't stop it.
  1. Block SSRF at the URL level. Validate protocols, block private IP ranges, block cloud metadata endpoints. Never fetch a user-supplied URL raw.
  1. Audit dependencies weekly. npm audit in CI. Dependabot enabled. No excuses.
  1. Secrets never leave the server. No NEXT_PUBLIC_ for API keys. No tokens in localStorage. httpOnly cookies for sessions.
  1. Treat the OWASP Top 10 as your deployment checklist. If your app doesn't pass all ten, it is not ready for production.

Security is not a feature you add later. It is the foundation you build on. Every project I ship — whether it is a Web3 dApp, an e-commerce platform, or a SaaS dashboard — starts with these patterns. If you want a professional security audit for your Next.js application, that is exactly what I do.


About the Author

Uvin Vindula is a Web3 and AI engineer based in Sri Lanka and UK, building production-grade applications and delivering security audits under the handle @IAMUVIN. He applies the OWASP Top 10 to every web project and brings smart contract auditing discipline to traditional web application security. His work spans full-stack development with Next.js and TypeScript, blockchain security with Solidity and Foundry, and AI product engineering with the Claude API. Find him at uvin.lk or reach out at contact@uvin.lk.

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.