Serverless Function 401 Error Despite Valid CRON_SECRET

Vercel Serverless Function Authentication Issue: 401 Unauthorized Despite Valid CRON_SECRET

Issue Description

I'm experiencing a persistent 401 Unauthorized error with my serverless functions, specifically in a cron job workflow. My setup includes:

  • A cron-triggered endpoint /api/cron-process-notifications.js that successfully authenticates
  • This endpoint then calls another endpoint /api/notification-trigger using axios
  • Both endpoints use the same CRON_SECRET environment variable for authentication
  • The token is being correctly passed in the Authorization header

What I've Confirmed

  • Environment Variables: The same CRON_SECRET value is set identically across ALL environments
  • Token Freshness: I generated a brand new CRON_SECRET just yesterday
  • Code Review: The authorization headers are properly formatted in the requests
  • Deployment: I've redeployed the application after updating the secret

Error Details

The cron job successfully triggers my first endpoint, but when that endpoint tries to call my second endpoint, I get this error:

[CRON] Error in scheduled notification processing: AxiosError: Request failed with status code 401

The logs show that the Authorization header is being sent correctly:

Authorization: 'Bearer xxxx'

The Confusing Part: Support's Response

Support suggested the token might be "invalid or expired" and that I should:

  1. Check if the token is hardcoded (it's not, it uses process.env.CRON_SECRET)
  2. Check if the endpoint uses a different auth mechanism (it doesn't, the code clearly shows it checks for the CRON_SECRET)
  3. Regenerate the token (I already did this yesterday)

What doesn't make sense is:

  • The token cannot be "expired" since it's a simple string, not a JWT with expiration
  • The code explicitly checks for the exact CRON_SECRET value:
    const authHeader = req.headers.authorization;
    const cronSecret = process.env.CRON_SECRET;
    

    if (cronSecret && (!authHeader || authHeader !== Bearer ${cronSecret})) {
    console.warn(‘[CRON] Authorization failed…’);
    return res.status(401).json({ error: ‘Unauthorized’ });
    }

  • The same token works for the first endpoint but not the second, despite identical authentication code

Code Flow

  1. Cron triggers /api/cron-process-notifications.js
  2. This validates Bearer ${process.env.CRON_SECRET} ✅ Success
  3. It then calls /api/notification-trigger passing the same token:
    const response = await axios.get(`${baseUrl}/api/notification-trigger`, {
      headers: {
        Authorization: `Bearer ${process.env.CRON_SECRET || ''}`
      }
    });
  4. This second endpoint returns 401 Unauthorized ❌ Failure

Question

  1. What could cause a 401 when the exact same token successfully authenticates against one endpoint but fails on another?
  2. Does vercel have a CRON_SECRET outside of the enviromental variables at the project level for?

First, are you sure you need to make network requests between your own functions? Since you own your backend, can you just make /api/cron-process-notifications.js and /api/notification-trigger call the same triggerNotification() function, rather than making one endpoint fetch the other? That would bypass all need for networking

Otherwise, how are you authenticating your second endpoint? If it’s code like this, you should be able to log both the authHeader and the CRON_SECRET side by side and see which one is coming up wrong

export function GET(request: NextRequest) {
  const authHeader = request.headers.get('authorization');
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', {
      status: 401,
    });
  }

if the 401 error is throwing before it even gets to that step, then make sure ${baseUrl}/api/notification-trigger is the correct path. If you try to curl that one directly, does that work? That would narrow down whether this is a function → function communication issue or just an issue with the second function.

If you’re in a protected deployment (like a preview deploy where you must be logged into Vercel to access it) then it will reject unauthenticated requests with a 401, which could cause what you’re seeing. You can add the x-vercel-bypass-automation header to let your request skip that login screen

That option would require a refactor of the application. However, even if I did that, that wouldn’t explain why I get a 401 if I try to hit all other two API endpoints with my CRON_SECRET.

First endpoint: Scheduled trigger (runs on a time schedule) (Works with CRON_SECRET)
Second endpoint: Finder (looks for work to be done) (Fails curl command with 401)
Third endpoint: Processor (does the actual work) (Fails curl command with 401)

Additionally, I’ve already followed your suggestion and logged the header on the other jobs, and when I check the Vercel logs, I can confirm that the bearer $cron_secret matches across all three jobs. They literally use the same authentication script.

Been troubleshooting this one for around a week or more now, finally reached out to support last night and they said my cron secret is wrong, but again that wouldn’t explain why I can curl into one and not the other.

it’s not just about logging the header (which you’ve verified is correct) but also the CRON_SECRET you’re comparing it to. I’m suspecting that CRON_SECRET may only exist in the scheduled cron function and might be undefined in the others, which would cause the mismatch

If it does exist, and you can prove that it matches in all of them like below, then it’s impossible for that if() block to execute, which means the 401 response you’re getting is actually coming from somewhere else

export function GET(request: NextRequest) {
  const authHeader = request.headers.get('authorization');
  const bearerToken = `Bearer ${process.env.CRON_SECRET}`
  const isAuthenticated = authHeader === bearerToken

  console.log({ authHeader, bearerToken, isAuthenticated})

  if (!isAuthenticated) {
    return new Response('Unauthorized', {
      status: 401,
    });
  }

I figured it out. it’s because vercel serverless lives on another domain and they cannot access cron_secrets.

Using system variables is one part of the problem. The other part of the problem is that Vercel serverless doesn’t use the domain name as the originator but the vercel domain + app build url convention that they created.

I had to build a method to hardcode my urls when originating from cron jobs.