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

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
Set request type to
GETAdd header:
Authorization: Bearer <your-token>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.