Sunday, December 28, 2025

AWS Amplify Gen 2: Why Your Secrets Aren't Working in Next.js API Routes

AWS Amplify Gen 2: Why Your Secrets Aren't Working in Next.js API Routes

If you've ever set up a secret in AWS Amplify Gen 2 using npx ampx sandbox secret set and then wondered why your Next.js API route is throwing "environment variable is not set" errors in production, you're not alone. This is one of the most common gotchas when deploying fullstack Next.js applications with Amplify Gen 2.

The Problem

You've done everything by the book (or so you thought):

npx ampx sandbox secret set STRIPE_SECRET_KEY
# ✓ Enter secret value: ***
# Done!

You've confirmed the secret exists. Your local sandbox works perfectly. But when you deploy to Amplify Hosting, your API route crashes:

Error: STRIPE_SECRET_KEY environment variable is not set
at /codebuild/output/src.../route.js:1:2219

What's going on?

The Root Cause: Two Different Systems

Amplify Gen 2 has two separate systems for managing configuration values, and they serve completely different purposes.

According to the official AWS documentation:

Secrets allow you to securely configure environment-specific values like social sign-in keys, function environment variables, function secrets, and other sensitive data needed by your application across environments.

The key phrase here is "function environment variables" and "function secrets." Amplify secrets are designed for Lambda functions, not for Next.js server-side code running in Amplify Hosting.

The Two Systems Explained

FeatureAmplify SecretsHosting Environment Variables
Set vianpx ampx sandbox secret set (local) or Amplify Console → Hosting → Secrets (branch)Amplify Console → Hosting → Environment variables
StorageAWS Systems Manager Parameter StoreAmplify Hosting managed service
Available toLambda functions defined in amplify/functions/Next.js server-side code (API routes, Server Components, Server Actions, middleware)
Access methodsecret('KEY_NAME') in function's resource.tsprocess.env.KEY_NAME directly

Where Secrets Are Stored

From the AWS docs, secrets are stored in AWS Systems Manager Parameter Store:

  • Shared secrets (all branches): /amplify/shared/<app-id>/<secret-key>
  • Branch-specific secrets: /amplify/<app-id>/<branchname>-branch-<unique-hash>/<secret-key>

These are only accessible to Lambda functions that explicitly reference them using the secret() function.

Understanding the Security Warning (And What It Really Means)

The AWS documentation contains this warning:

Do not store secret values in environment variables. Environment variables values are rendered in plaintext to the build artifacts and can be accessed by anyone with access to the build artifacts.

This warning specifically applies to Lambda function environment variables (when you don't use secret()), not to Hosting environment variables. Let me explain the actual difference.

Common Misconception: "I Can See Both in the Console"

You might be thinking: "But I can see both secrets AND environment variables in the Amplify Console. What's the difference?"

You're right - anyone with Amplify Console access can view both. The security difference isn't about Console visibility. It's about where the actual value ends up in your deployed infrastructure.

Lambda with secret() - Reference Only in Config

When you use secret():

// amplify/functions/my-function/resource.ts
export const myFunction = defineFunction({
  environment: {
    API_KEY: secret('MY_API_KEY'), // ✅ Safe
  },
})

What happens:

  • The actual value (sk_live_abc123...) stays in Parameter Store
  • Your Lambda configuration only contains a reference (like {{resolve:ssm-secure:/amplify/...}})
  • The value is fetched at runtime when the function executes
  • If someone exports your CloudFormation template, looks at .amplify/artifacts/, or views CloudFormation stack events, they see the reference, not the actual secret

Lambda with Plain Environment Variable - Value in Config

When you hardcode the value:

// amplify/functions/my-function/resource.ts
export const myFunction = defineFunction({
  environment: {
    API_KEY: 'sk_live_abc123...', // ❌ Dangerous!
  },
})

What happens:

  • The literal string sk_live_abc123... is written directly into the CloudFormation template
  • It's visible in:
    • .amplify/artifacts/ directory (committed to your build)
    • CloudFormation stack events in AWS Console
    • Lambda function configuration in AWS Console
    • The response from AWS get-app API
  • Anyone who can access these locations sees your actual secret in plaintext

Hosting Environment Variables - Different System Entirely

Environment variables set in Amplify Console → Hosting → Environment variables:

  • Are managed by Amplify's hosting service, not CloudFormation
  • Are not part of your backend deployment artifacts
  • Are injected at build/runtime by the hosting platform
  • Work similarly to how Vercel, Netlify, and other platforms handle env vars

Security Comparison Table

What can see the actual secret value?Lambda secret()Lambda plain env varHosting env var
Amplify Console (authorized users)✅ Yes✅ Yes✅ Yes
.amplify/artifacts/ directory❌ No (reference only)Yes - exposed!❌ No
CloudFormation template/events❌ No (reference only)Yes - exposed!❌ No
Lambda config in AWS Console❌ No (reference only)Yes - exposed!N/A
AWS get-app API response❌ NoYes - exposed!❌ No
Build logs (if accidentally logged)DependsYes - exposed!Possible

The Real Security Benefits of secret()

  1. Build artifacts are safe - If your .amplify/artifacts/ folder is accidentally committed, shared, or leaked, your secrets are not exposed
  2. CloudFormation is safe - Stack events and templates don't contain actual values
  3. Audit trail - Parameter Store has CloudTrail logging for access
  4. Secret rotation - You can update the secret value without redeploying your function

What This Means Practically

The protection is against:

  • Accidental exposure in build artifacts
  • Secrets appearing in CloudFormation logs/events
  • Leaking secrets if someone gains read access to your deployment pipeline

The protection is NOT against:

  • Authorized users viewing secrets in Amplify Console (they can see both)
  • Someone with full AWS account access

When to Use Each Approach

Use Amplify Secrets for Lambda Functions

// amplify/functions/stripe-webhook/resource.ts
import { defineFunction, secret } from '@aws-amplify/backend'

export const stripeWebhook = defineFunction({
  name: 'stripe-webhook',
  entry: './handler.ts',
  environment: {
    // ✅ Secret - only a reference in CloudFormation, value fetched at runtime
    STRIPE_SECRET_KEY: secret('STRIPE_SECRET_KEY'),
    STRIPE_WEBHOOK_SECRET: secret('STRIPE_WEBHOOK_SECRET'),
  },
})

Access in your handler with type safety:

// amplify/functions/stripe-webhook/handler.ts
import { env } from '$amplify/env/stripe-webhook'

export const handler = async (event) => {
  // Type-safe access - value fetched from Parameter Store at runtime
  const stripe = new Stripe(env.STRIPE_SECRET_KEY)
  // ...
}

Use Hosting Environment Variables for Next.js

For Next.js server-side code, use Hosting environment variables:

  1. Go to AWS Amplify Console → Your app → HostingEnvironment variables
  2. Add your variables (e.g., STRIPE_SECRET_KEY, OPENAI_API_KEY)
// src/app/api/subscriptions/create/route.ts
import Stripe from 'stripe'

// This runs in Amplify Hosting
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
})

export async function POST(request: Request) {
  // Your API logic...
}

This is the standard pattern used by most Next.js applications on Vercel, Netlify, and similar platforms.

Use AWS Secrets Manager for Maximum Security (Next.js)

If you have compliance requirements or need the highest level of security for Next.js server-side code, fetch secrets at runtime from AWS Secrets Manager:

// lib/secrets.ts
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager'

const client = new SecretsManagerClient({
  region: process.env.AWS_REGION,
})

// Cache to avoid fetching on every request
const secretCache = new Map<string, { value: string; expiry: number }>()
const CACHE_TTL = 5 * 60 * 1000 // 5 minutes

export async function getSecret(secretName: string): Promise<string> {
  const cached = secretCache.get(secretName)
  if (cached && cached.expiry > Date.now()) {
    return cached.value
  }

  const command = new GetSecretValueCommand({ SecretId: secretName })
  const response = await client.send(command)
  const value = response.SecretString!

  secretCache.set(secretName, {
    value,
    expiry: Date.now() + CACHE_TTL,
  })

  return value
}

Trade-offs:

  • ✅ Secrets never in environment variables or build config
  • ✅ Can rotate secrets without redeploying
  • ✅ Full audit trail via CloudTrail
  • ❌ Adds latency (mitigated by caching)
  • ❌ More complex setup
  • ❌ Requires IAM permissions for the Hosting compute role

A Real-World Example: Stripe Integration

Let's say you have a Stripe integration with:

  • A Next.js API route for creating subscriptions
  • A Lambda function for processing webhooks

The API Route (Next.js) - Hosting Environment Variables

// src/app/api/subscriptions/create/route.ts
import Stripe from 'stripe'

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error('STRIPE_SECRET_KEY environment variable is not set')
}

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: '2024-12-18.acacia',
})

export async function POST(request: Request) {
  const { priceId, customerId } = await request.json()

  const subscription = await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
  })

  return Response.json({ subscription })
}

Setup: Add STRIPE_SECRET_KEY in Amplify Console → Hosting → Environment variables

The Lambda Function - Amplify Secrets

// amplify/functions/stripe-webhook/resource.ts
import { defineFunction, secret } from '@aws-amplify/backend'

export const stripeWebhook = defineFunction({
  name: 'stripe-webhook',
  entry: './handler.ts',
  environment: {
    STRIPE_SECRET_KEY: secret('STRIPE_SECRET_KEY'),
    STRIPE_WEBHOOK_SECRET: secret('STRIPE_WEBHOOK_SECRET'),
  },
})
// amplify/functions/stripe-webhook/handler.ts
import { env } from '$amplify/env/stripe-webhook'
import Stripe from 'stripe'

const stripe = new Stripe(env.STRIPE_SECRET_KEY)

export const handler = async (event) => {
  const sig = event.headers['stripe-signature']
  const webhookEvent = stripe.webhooks.constructEvent(
    event.body,
    sig,
    env.STRIPE_WEBHOOK_SECRET,
  )
  // Process webhook...
}

Setup:

  • Local: npx ampx sandbox secret set STRIPE_SECRET_KEY
  • Branch: Amplify Console → Hosting → Secrets → Manage secrets

Quick Reference

Your code runs in...Recommended approachWhy
Lambda Functionssecret() in resource.tsKeeps secrets out of CloudFormation and build artifacts
Next.js (standard)Hosting Environment VariablesStandard pattern, similar to Vercel/Netlify
Next.js (high security)AWS Secrets Manager at runtimeSecrets never in any config, full audit trail

Summary

  1. Amplify secrets (secret() function) are for Lambda functions - they keep the actual secret value out of CloudFormation templates and build artifacts, storing only a reference that's resolved at runtime.

  2. Hosting environment variables are for Next.js server-side code - they're the standard approach used by most platforms, managed by Amplify's hosting service (not CloudFormation).

  3. The AWS warning about plaintext applies to Lambda function environment variables when you don't use secret(). The actual value gets baked into CloudFormation, making it visible in multiple places.

  4. Console visibility is the same - authorized users can see both secrets and env vars in the Amplify Console. The security difference is about preventing exposure in build artifacts, CloudFormation, and deployment pipelines.

  5. For maximum security in Next.js, use AWS Secrets Manager to fetch secrets at runtime - but this adds complexity and latency.

References


Have you run into this issue? Share your experience in the comments!