Skip to main content

Command Palette

Search for a command to run...

Building a secure internal API in Next.js: Bearer token auth without OAuth

Updated
5 min read
Building a secure internal API in Next.js: Bearer token auth without OAuth
E

Building Elyvora US, a platform to help people discover products smarter. I write about startups, SEO, and lessons from building a business.

Most authentication tutorials dive straight into OAuth, NextAuth, or Auth0. But what if you just need a simple, bulletproof read-only API for your mobile app? Sometimes a bearer token is all you need.

The use case:

We needed a private API endpoint to monitor our website's indexing progress from a Flutter mobile app. The requirements were simple:

  • Read-only access to internal metrics (page counts, Google indexing status)

  • No user authentication needed (single admin app)

  • Mobile-first (iOS/Android with offline caching)

  • SEO-safe (not crawlable, not in sitemap)

OAuth felt like overkill. We needed something simpler.

Why bearer tokens for internal APIs?

Before reaching for complex auth flows, consider bearer tokens when:

✅ Your API serves a single internal client (mobile app, dashboard, monitoring tool)
✅ You need read-only access (no user sessions, no write operations)
✅ The token can be securely embedded in your app
✅ You control both the API and the client

❌ Skip bearer tokens if you need:

  • Multi-user authentication

  • Fine-grained permissions

  • Token refresh flows

  • Public API access

Implementation: Next.js app router

Here's how we built it in Next.js 14 with the app router.

1. Generate a secure token

First, create a cryptographically secure token:

node -e "console.log('elk_' + require('crypto').randomBytes(32).toString('hex'))"

Output example:

elk_15cd29a46abbdfa1ca1f2b95ca6a64525937b4e7c6a6932288ae085ceabe3d73

Store it in .env:

INTERNAL_API_SECRET=elk_15cd29a46abbdfa1ca1f2b95ca6a64525937b4e7c6a6932288ae085ceabe3d73

Why the elk_ prefix? Token prefixes make it easy to identify leaked credentials in logs or repos (similar to GitHub's ghp_ or Stripe's sk_).

2. Create the API route

In app/api/internal/metrics/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';

export async function GET(request: NextRequest) {
  // 1. Extract bearer token
  const authHeader = request.headers.get('authorization');
  const token = authHeader?.replace('Bearer ', '');

  // 2. Validate token
  if (!token || token !== process.env.INTERNAL_API_SECRET) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { 
        status: 401,
        headers: {
          'WWW-Authenticate': 'Bearer realm="Internal API"',
        },
      }
    );
  }

  // 3. Fetch metrics from database
  try {
    const [blogCount, productCount, staticPages] = await Promise.all([
      db.blogPost.count({ where: { status: 'published' } }),
      db.product.count({ where: { status: 'active' } }),
      Promise.resolve(9), // Static pages count
    ]);

    const totalUrls = blogCount + productCount + staticPages + 4; // +4 for categories

    // 4. Return metrics with security headers
    return NextResponse.json(
      {
        success: true,
        data: {
          totalUrls,
          breakdown: {
            blogPosts: blogCount,
            products: productCount,
            staticPages,
            categories: 4,
          },
          lastUpdated: new Date().toISOString(),
        },
      },
      {
        status: 200,
        headers: {
          'Cache-Control': 'private, no-store, must-revalidate',
          'X-Robots-Tag': 'noindex, nofollow',
        },
      }
    );
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

3. Key security features

A. Simple Token Validation

if (!token || token !== process.env.INTERNAL_API_SECRET) {
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

No database lookups, no hashing comparisons. For internal APIs with a single token, direct string comparison is sufficient.

B. Security Headers

headers: {
  'Cache-Control': 'private, no-store, must-revalidate',  // Never cache
  'X-Robots-Tag': 'noindex, nofollow',                     // Block search engines
}
  • Cache-Control: Prevents browsers and proxies from caching sensitive data

  • X-Robots-Tag: Ensures Google/Bing won't index this endpoint

C. WWW-Authenticate Header

headers: {
  'WWW-Authenticate': 'Bearer realm="Internal API"',
}

Proper HTTP 401 response includes this header, making your API standards-compliant.

4. Exclude from Sitemap

In app/sitemap.ts, make sure internal routes are not included:

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // Only include public-facing pages
  const routes = ['/', '/about', '/blog', '/products', '/contact'];

  // Never include /api/internal/* routes
  return routes.map(route => ({
    url: `https://yourdomain.com${route}`,
    lastModified: new Date(),
  }));
}

5. Flutter Client Integration

Here's how we consume this API from our Flutter mobile app:

Future<Map<String, dynamic>?> fetchMetrics() async {
  const String apiUrl = 'https://elyvora.us/api/internal/metrics';
  const String bearerToken = 'elk_15cd29a46abbdfa1ca1f2b95ca6a64525937b4e7c6a6932288ae085ceabe3d73';

  try {
    final response = await http.get(
      Uri.parse(apiUrl),
      headers: {
        'Authorization': 'Bearer $bearerToken',
        'Accept': 'application/json',
      },
    );

    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      return data['data'];
    } else if (response.statusCode == 401) {
      print('Authentication failed');
      return null;
    }
  } catch (e) {
    print('Network error: $e');
    return null;
  }
  return null;
}

Security note: For production, store the token in Flutter's secure storage using flutter_secure_storage instead of hardcoding it.

Testing Your API

Using cURL

# Valid token
curl -H "Authorization: Bearer elk_15cd29a46abbdfa1ca1f2b95ca6a64525937b4e7c6a6932288ae085ceabe3d73" \
  https://yourdomain.com/api/internal/metrics

# Invalid token
curl -H "Authorization: Bearer wrong_token" \
  https://yourdomain.com/api/internal/metrics
# Returns: {"error":"Unauthorized"}

# No token
curl https://yourdomain.com/api/internal/metrics
# Returns: {"error":"Unauthorized"}

Using Postman

  1. Set request type to GET

  2. Add header: Authorization: Bearer <your-token>

  3. Send request

Expected response:

{
  "success": true,
  "data": {
    "totalUrls": 79,
    "breakdown": {
      "blogPosts": 22,
      "products": 44,
      "staticPages": 9,
      "categories": 4
    },
    "lastUpdated": "2026-01-13T18:45:23.000Z"
  }
}

When to Upgrade to OAuth

This bearer token approach works great for internal APIs, but you should upgrade to OAuth when:

  • You need multiple users with different permissions

  • You want token expiration and refresh flows

  • Your API becomes public-facing

  • You need audit logs for who accessed what

  • Compliance requires formal authentication (SOC 2, HIPAA, etc.)

Real-World Results

We built this API to track Google Search Console indexing progress for our affiliate website. The Flutter app pulls metrics every hour, stores them locally, and displays indexing trends over time.

Setup time: 30 minutes
Lines of code: ~60
Complexity: Minimal
Maintenance: Zero (no dependencies to update)

For our use case—monitoring site metrics from a single mobile app—this simple bearer token approach was the perfect fit. No OAuth complexity, no database lookups for token validation, just a clean, secure API.


Want to see this in action? Check out elyvora.us, where we use this exact API to monitor indexing progress across 70+ URLs (blog posts, product pages, and static pages).

Key Takeaways

✅ Bearer tokens are perfect for internal, read-only APIs
✅ Always use cryptographically secure random tokens (32+ bytes)
✅ Add security headers: Cache-Control: private and X-Robots-Tag: noindex
✅ Exclude internal endpoints from your sitemap
✅ Keep tokens in environment variables, never commit them

Sometimes the simplest solution is the best solution. Not every API needs OAuth.