Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-tables-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@e2b/cli': patch
---

Allow template build and create commands to authenticate with either an API key or access token.
5 changes: 2 additions & 3 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@ e2b auth login

> [!NOTE]
> To authenticate without the ability to open the browser, provide
> `E2B_ACCESS_TOKEN` as an environment variable. You can find your token
> in Account Settings under the Team selector at [e2b.dev/dashboard](https://e2b.dev/dashboard). Then use the CLI like this:
> `E2B_ACCESS_TOKEN=sk_e2b_... e2b template build`.
> `E2B_API_KEY` or `E2B_ACCESS_TOKEN` as an environment variable. Then use the
> CLI like this: `E2B_API_KEY=e2b_... e2b template build`.

> [!IMPORTANT]
> Note the distinction between `E2B_ACCESS_TOKEN` and `E2B_API_KEY`.
Expand Down
52 changes: 38 additions & 14 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@
export let accessToken = process.env.E2B_ACCESS_TOKEN
export const teamId = process.env.E2B_TEAM_ID

function resolveAPIKey() {
// If apiKey is not already set (either from env var or from user config), try to get it from config file
if (!apiKey) {
const userConfig = getUserConfig()
apiKey = userConfig?.teamApiKey
}

return apiKey
}

function resolveAccessToken() {
// If accessToken is not already set (either from env var or from user config), try to get it from config file
if (!accessToken) {
const userConfig = getUserConfig()
accessToken = userConfig?.accessToken
}

return accessToken
}

const authErrorBox = (keyName: string) => {
let link
let msg
Expand Down Expand Up @@ -45,17 +65,13 @@
}

export function ensureAPIKey() {
// If apiKey is not already set (either from env var or from user config), try to get it from config file
if (!apiKey) {
const userConfig = getUserConfig()
apiKey = userConfig?.teamApiKey
}
const resolvedApiKey = resolveAPIKey()

if (!apiKey) {
if (!resolvedApiKey) {
console.error(authErrorBox('E2B_API_KEY'))
process.exit(1)
} else {
return apiKey
return resolvedApiKey
}
}

Expand All @@ -69,20 +85,28 @@
}

export function ensureAccessToken() {
// If accessToken is not already set (either from env var or from user config), try to get it from config file
if (!accessToken) {
const userConfig = getUserConfig()
accessToken = userConfig?.accessToken
}
const resolvedAccessToken = resolveAccessToken()

if (!accessToken) {
if (!resolvedAccessToken) {
console.error(authErrorBox('E2B_ACCESS_TOKEN'))
process.exit(1)
} else {
return accessToken
return resolvedAccessToken
}
}

export function ensureAPIKeyOrAccessToken() {
const resolvedApiKey = resolveAPIKey()
const resolvedAccessToken = resolveAccessToken()
Comment on lines +99 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid mixing an explicit API key with saved tokens

When E2B_API_KEY is set on a machine that also has a saved ~/.e2b/config.json, this still calls resolveAccessToken(), loads the saved access token, and returns both credentials. Callers such as template build then prefer accessToken for Docker auth, while template create sends both headers, so a stale or wrong local token can override the explicit API key and make the new API-key-only path fail or use the wrong account. Stop resolving the fallback token once an API key has been chosen, or otherwise preserve a single credential source.

Useful? React with 👍 / 👎.


if (resolvedApiKey || resolvedAccessToken) {
return { apiKey: resolvedApiKey, accessToken: resolvedAccessToken }
}

console.error(authErrorBox('E2B_API_KEY'))
process.exit(1)
}

Check warning on line 109 in packages/cli/src/api.ts

View check run for this annotation

Claude / Claude Code Review

Auth error only mentions E2B_API_KEY

When neither `E2B_API_KEY` nor `E2B_ACCESS_TOKEN` is set, `ensureAPIKeyOrAccessToken()` calls `authErrorBox('E2B_API_KEY')`, which only mentions the API key env var and the API-key dashboard URL. Since this function explicitly accepts either credential (and the README updated in this PR tells users they may use either), the error message is misleading for users who authenticate with an access token — consider extending `authErrorBox` to mention both options here.
Comment on lines +98 to 109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 When neither E2B_API_KEY nor E2B_ACCESS_TOKEN is set, ensureAPIKeyOrAccessToken() calls authErrorBox('E2B_API_KEY'), which only mentions the API key env var and the API-key dashboard URL. Since this function explicitly accepts either credential (and the README updated in this PR tells users they may use either), the error message is misleading for users who authenticate with an access token — consider extending authErrorBox to mention both options here.

Extended reasoning...

What the bug is

In packages/cli/src/api.ts (lines 98–109), the new ensureAPIKeyOrAccessToken() function accepts either E2B_API_KEY or E2B_ACCESS_TOKEN as valid authentication. However, when both are missing it falls through to console.error(authErrorBox('E2B_API_KEY')). The authErrorBox helper switches on keyName and only supports two single-key variants:

  • 'E2B_API_KEY' → produces "set the E2B_API_KEY environment variable. Visit https://e2b.dev/dashboard?tab=keys to get the API key."
  • 'E2B_ACCESS_TOKEN' → produces the analogous message for the access token.

So a user who normally authenticates with E2B_ACCESS_TOKEN and forgets/typos it in CI will be told to set the wrong env var and pointed to the wrong dashboard tab.

Why the existing code doesn't prevent it

authErrorBox has no combined mode — its switch only knows about the two individual key names. The new ensureAPIKeyOrAccessToken reuses the API-key branch verbatim, which conflicts with this PR's own intent of accepting either credential. The README was updated in the same PR to advertise both env vars (packages/cli/README.md: "provide E2B_API_KEY or E2B_ACCESS_TOKEN as an environment variable"), so the error path is now inconsistent with the documented behavior.

Step-by-step proof

  1. User runs e2b template list in CI with no ~/.e2b/config.json and an unset/typo'd E2B_ACCESS_TOKEN (and no E2B_API_KEY).
  2. list.ts calls ensureAPIKeyOrAccessToken().
  3. resolveAPIKey() returns undefined (no env var, no user config).
  4. resolveAccessToken() returns undefined for the same reason.
  5. The if (resolvedApiKey || resolvedAccessToken) branch is false.
  6. Code reaches console.error(authErrorBox('E2B_API_KEY')) (api.ts:106).
  7. The user sees: "…set the E2B_API_KEY environment variable. Visit https://e2b.dev/dashboard?tab=keys to get the API key." — with no mention that E2B_ACCESS_TOKEN would also have worked, and a link that is the wrong dashboard tab for their authentication method.

Impact

Purely a UX/diagnostic issue, not a functional break. The first line of the error ("Run e2b auth login") still points at a working remediation, so most interactive users recover. The degradation is concentrated on the CI/CD diagnostic path, where the env-var hint matters most. Because of that, nit severity is appropriate.

How to fix

Add a combined branch to authErrorBox (e.g. an 'E2B_API_KEY_OR_ACCESS_TOKEN' case) that lists both env vars and both dashboard URLs, and pass that key from ensureAPIKeyOrAccessToken. Alternatively, change authErrorBox to accept an array of key names and render a list. Either approach keeps the existing single-key callers (ensureAPIKey, ensureAccessToken) working unchanged.

/**
* Resolve team ID with proper precedence:
* 1. CLI --team flag
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/commands/template/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import {
client,
connectionConfig,
ensureAccessToken,
ensureAPIKeyOrAccessToken,
resolveTeamId,
} from 'src/api'
import { configName, getConfigPath, loadConfig, saveConfig } from 'src/config'
Expand Down Expand Up @@ -246,7 +246,8 @@
})
}

const accessToken = ensureAccessToken()
const credentials = ensureAPIKeyOrAccessToken()
const dockerAuthToken = credentials.accessToken ?? credentials.apiKey!
process.stdout.write('\n')

const newName = opts.name?.trim()
Expand Down Expand Up @@ -374,13 +375,13 @@
true
)

if (imageUriMask == undefined) {
try {
child_process.execSync(
`echo "${accessToken}" | docker login docker.${connectionConfig.domain} -u _e2b_access_token --password-stdin`,
`echo "${dockerAuthToken}" | docker login docker.${connectionConfig.domain} -u _e2b_access_token --password-stdin`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Agentic Security Review
Severity: MEDIUM
The Docker login command now interpolates dockerAuthToken directly into a shell command string (echo "${dockerAuthToken}" | ...). Because this value can now be E2B_API_KEY, the secret may be exposed through process inspection or command logging on shared hosts/CI runners while the command executes.

Impact: Team-scoped API credentials can leak to other local processes/users, enabling unauthorized access to protected template and registry operations.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is supported by docker reverse proxy

{
stdio: 'inherit',
cwd: root,

Check failure on line 384 in packages/cli/src/commands/template/build.ts

View check run for this annotation

Claude / Claude Code Review

Docker login uses _e2b_access_token username when authenticating with API key

After this PR, `dockerAuthToken` (build.ts:250) can be an API key when only `E2B_API_KEY` is set, but `docker login` (build.ts:381) and `buildWithProxy.ts` (the `_e2b_access_token:...` Basic-auth header at L42 and the `?account=_e2b_access_token` query param at L127) still send the literal username `_e2b_access_token`. The README now explicitly markets `E2B_API_KEY=e2b_... e2b template build` as supported, so this is the exact path the PR is enabling. Please confirm server-side that the registry
Comment on lines 378 to 384

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 After this PR, dockerAuthToken (build.ts:250) can be an API key when only E2B_API_KEY is set, but docker login (build.ts:381) and buildWithProxy.ts (the _e2b_access_token:... Basic-auth header at L42 and the ?account=_e2b_access_token query param at L127) still send the literal username _e2b_access_token. The README now explicitly markets E2B_API_KEY=e2b_... e2b template build as supported, so this is the exact path the PR is enabling. Please confirm server-side that the registry / token endpoint accepts API keys under _e2b_access_token (or make the username conditional, e.g. _e2b_api_key when sending an API key).

Extended reasoning...

What changed

Before this PR, build.ts called ensureAccessToken() so the credential passed to docker login and to buildWithProxy was always an access token. After the PR, that line is replaced with:

const credentials = ensureAPIKeyOrAccessToken()
const dockerAuthToken = credentials.accessToken ?? credentials.apiKey!

So dockerAuthToken is the access token if present, otherwise the API key. dockerAuthToken is then plumbed into three places that were originally written assuming an access token:

  1. build.ts:381echo "${dockerAuthToken}" | docker login docker.${domain} -u _e2b_access_token --password-stdin
  2. buildWithProxy.ts:42Buffer.from(\_e2b_access_token:${dockerAuthToken}`).toString('base64')used as theBasicauth header against/v2/token` and as fallback in the proxy.
  3. buildWithProxy.ts:127fetch(\https://docker.${domain}/v2/token?account=_e2b_access_token&scope=...\`, ...)– theaccount` query param is also hardcoded.

Why this is worth flagging

The whole point of the PR (per the changeset and the README diff) is to let users run E2B_API_KEY=e2b_... e2b template build. That command is the v1 build path which performs docker login against the E2B docker registry. If the registry/token endpoint only honours _e2b_access_token for actual access tokens, API-key-only users will fail at docker login (or the proxy token exchange) — silently nullifying the new flow that this PR is named after.

Step-by-step proof of the new code path

  1. User sets only E2B_API_KEY=e2b_... (no E2B_ACCESS_TOKEN, no logged-in config), as the new README example instructs.
  2. ensureAPIKeyOrAccessToken() (api.ts) returns { apiKey: 'e2b_...', accessToken: undefined }.
  3. build.ts:250 resolves dockerAuthToken = undefined ?? 'e2b_...' → the API key.
  4. build.ts:381 runs echo "e2b_..." | docker login docker.<domain> -u _e2b_access_token --password-stdin. The username sent to the registry is _e2b_access_token but the password is an API key.
  5. If docker push falls back to the proxy, buildWithProxy.ts:42 builds Basic base64(_e2b_access_token:e2b_...) and buildWithProxy.ts:127 requests /v2/token?account=_e2b_access_token&scope=... with that header.

Addressing the refutation

A refuting verifier argued that _e2b_access_token is a Docker-registry convention like AWS ECR's AWS or GCR's _json_key, where the username is opaque and the password drives auth. That is plausible and may well be true here — but it's exactly what we cannot verify from the CLI source. The bug submitters and confirming verifiers are also explicit that this hinges on server behaviour: "Worth verifying server-side". The action being requested is small and concrete: the PR author can either (a) confirm in the PR description that the registry treats the username as opaque so an API key under _e2b_access_token works, or (b) make the username conditional (e.g. _e2b_api_key when credentials.accessToken is undefined) so the username matches the credential type and is robust to future server-side changes. Even if the server currently accepts both, the hardcoded label is now semantically wrong post-PR.

}
)
} catch (err: any) {
Expand Down Expand Up @@ -449,7 +450,7 @@
await buildWithProxy(
userConfig,
connectionConfig,
accessToken,
dockerAuthToken,
template,
root
)
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/template/buildWithProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const PORT = 49984
export async function buildWithProxy(
userConfig: UserConfig | null,
connectionConfig: e2b.ConnectionConfig,
accessToken: string,
dockerAuthToken: string,
template: { templateID: string; buildID: string },
root: string
) {
Expand All @@ -39,7 +39,7 @@ export async function buildWithProxy(
})

const accessTokenBase64Encoded = Buffer.from(
`_e2b_access_token:${accessToken}`
`_e2b_access_token:${dockerAuthToken}`
).toString('base64')

const proxyServer = await proxy(
Expand Down
10 changes: 4 additions & 6 deletions packages/cli/src/commands/template/create.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as boxen from 'boxen'
import * as commander from 'commander'
import { defaultBuildLogger, Template, TemplateClass } from 'e2b'
import { connectionConfig, ensureAccessToken, ensureAPIKey } from 'src/api'
import { connectionConfig, ensureAPIKeyOrAccessToken } from 'src/api'
import {
defaultDockerfileName,
fallbackDockerfileName,
Expand Down Expand Up @@ -68,8 +68,7 @@ export const createCommand = new commander.Command('create')
}
) => {
try {
// Ensure we have access token
ensureAccessToken()
const credentials = ensureAPIKeyOrAccessToken()
process.stdout.write('\n')

// Validate template name
Expand Down Expand Up @@ -131,8 +130,6 @@ export const createCommand = new commander.Command('create')

console.log('\nBuilding sandbox template...\n')

// Prepare API credentials for SDK
const apiKey = ensureAPIKey()
const domain = connectionConfig.domain

// Build the template using SDK
Expand All @@ -142,7 +139,8 @@ export const createCommand = new commander.Command('create')
cpuCount: cpuCount,
memoryMB: memoryMB,
skipCache: opts.noCache,
apiKey: apiKey,
apiKey: credentials.apiKey,
accessToken: credentials.accessToken,
domain: domain,
onBuildLogs: defaultBuildLogger(),
})
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/template/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as e2b from 'e2b'

import { listAliases } from '../../utils/format'
import { sortTemplatesAliases } from 'src/utils/templateSort'
import { client, ensureAccessToken, resolveTeamId } from 'src/api'
import { client, ensureAPIKeyOrAccessToken, resolveTeamId } from 'src/api'
import { teamOption } from '../../options'
import { handleE2BRequestError } from '../../utils/errors'

Expand All @@ -16,7 +16,7 @@ export const listCommand = new commander.Command('list')
.action(async (opts: { team: string; format: string }) => {
try {
const format = opts.format || 'pretty'
ensureAccessToken()
ensureAPIKeyOrAccessToken()
process.stdout.write('\n')

const templates = await listSandboxTemplates({
Expand Down
Loading