Envless signs you in with a code emailed to your address, so by default the account is exactly as strong as that inbox. Two-factor authentication adds a second, unrelated factor on top: a six-digit code from an authenticator app on your phone. It is off by default, it is per user, and you turn it on for yourself from Account settings. Nobody can turn it on for you. A workspace owner or admin can now require it of every member, which is a different thing from enrolling them: see Requiring it across a workspace.
What it is
| Property | Value |
|---|---|
| Standard | TOTP, RFC 6238, implemented in the API with node:crypto and no third-party library |
| Algorithm | SHA-1, 6 digits, a new code every 30 seconds |
| Clock drift | One step either side is accepted, so a code is usable for roughly 90 seconds |
| Seed | 20 random bytes, base32 encoded, shown once as a QR code and as a typed key |
| Seed at rest | AES-256-GCM encrypted before it is stored. If the server has no encryption key configured, enrollment fails with 500 rather than writing a seed in the clear |
| Apps | Anything that reads a standard otpauth:// URI: 1Password, Bitwarden, Authy, Google Authenticator, Yubico Authenticator |
There is still no SMS code, no WebAuthn, no passkey and no hardware key. The second factor is an authenticator app or a recovery code, and nothing else.
Turn it on
Open Account settings
envless.cloud/dashboard/account, the Two-Factor Authentication section, then Enable two-factor.
Scan the QR code
Envless generates a seed and shows it as a QR code, with the same key printed underneath in groups of four characters for apps that cannot scan. Nothing is switched on at this point: the seed is stored as a pending enrollment and it expires in 15 minutes if you stop here.
Enter the code your app shows
Six digits. The form submits on its own once the sixth digit lands.
Confirm the account challenge
Turning the factor on is an account-level action, so Envless emails you a separate verification code and asks for that too. Both are required: the app code proves you hold the new device, the emailed code proves you still hold the account. A stolen browser session alone cannot enroll an attacker's phone.
Save the recovery codes
Ten codes appear, with Copy all and Download buttons. They are shown exactly once. Tick I saved these recovery codes somewhere safe to close the dialog, and mean it: this list is the only way back into the account if you lose the phone.
Every other browser you are signed in on stops working normally the moment the factor is on. Those sessions are not signed out, but their next request is refused and they land on the code screen, where they have to enter a code from the authenticator app, or a recovery code, before they can continue. That includes your own other laptop and your phone browser.
Signing in afterwards
The first factor does not change. You still type your email and the six-character code Envless mails you, or use Continue with Google or Continue with GitHub. What changes is what happens next: instead of landing on the dashboard, you land on a second screen asking for the six digits from your authenticator app. A link under the field switches the same screen to a recovery code, and Cancel signs you back out.
At sign-in the second factor is the app or a recovery code, never an emailed code. That is deliberate. The first factor is already a code sent to your inbox, so offering an emailed second factor would let one compromised mailbox clear both gates. In-app actions are the other way round, because there Envless cannot assume the phone is still to hand. See Step-up verification.
How long a verification lasts
A successful verification is recorded against the exact sign-in it belongs to, not against the browser, and it lasts 30 days. Inside that window the token your browser holds is refreshed roughly hourly without asking you for anything. Signing out and signing back in starts a new sign-in, which has no verification recorded against it, so it asks again.
Wrong codes
| Rule | Behaviour |
|---|---|
| Attempt limit | 10 wrong app codes inside a rolling 15 minutes locks the app code path |
| The lock lifts on its own | It clears 15 minutes after the last failed attempt. There is no permanent lockout and nothing to contact anyone about |
| Recovery codes keep working while locked | The lock covers app codes only, so a lost phone plus a wrong-code loop still leaves you a way in |
| A correct code clears the counter | A valid app code or a valid recovery code resets it to zero. Until one lands, each further wrong code re-arms the 15 minute lock |
| No replay | A code from the 30 second step you already used, or from any earlier step, is refused and counts as a failed attempt |
Recovery codes
Ten codes are issued the moment two-factor is switched on. Each is 10 characters drawn from a 32-symbol alphabet with the ambiguous I, O, 0 and 1 left out, printed in two groups of five. Case and hyphens are ignored when you type one back.
| Property | Detail |
|---|---|
| Count | 10, issued together |
| Single use | A code is consumed the first time it works and never works again |
| Shown once | The dialog that lists them is the only time they are displayed. Reopening the section shows a count, never the codes |
| Stored as hashes | Only a SHA-256 digest of each code is stored, so nobody at Envless can read them back or recite one to you |
| Regenerate | Regenerate recovery codes on the Account page issues a fresh set of 10 and deletes the old set outright, used and unused alike. It asks for the account challenge first, which a recovery code satisfies, so you can do it from a machine that no longer has the authenticator app |
The Account page shows how many are left, and warns once you are down to 3 or fewer. Regenerating is the fix, and it invalidates whatever is left of the old list, so replace the copy you saved.
If you lose the device
Use a recovery code. On the sign-in screen, click Lost your device? Enter one of your recovery codes instead., paste one in, and you are through. Envless emails you a notice saying a recovery code was used and how many are left. Then set the factor up again on the new phone: turn it off from Account settings, turn it back on, and scan the new QR code. The old seed is not transferable.
If the phone and the recovery codes are both gone, the account is unreachable. There is no self-service path back in, no identity-check flow, and no support reset. The recovery codes are stored as hashes, the seed is encrypted, and nothing in the dashboard or the API can turn the factor off without one of the two. Store the codes somewhere separate from the phone, in a password manager or on paper, before you need them.
A recovery code gets you back into the account. It does not unlock the workspace. Variable values are encrypted in your browser under the workspace passphrase, which is a separate secret Envless never receives, so a new device still shows a locked workspace until you type it. See Unlocking the dashboard.
Turn it off
Account settings, Turn off, confirm, then answer the account challenge with a code from your authenticator app, a recovery code, or an emailed code. Switching it off deletes the seed, deletes every remaining recovery code, drops every recorded verification, and emails you that it happened.
Reaching this endpoint at all already costs a second factor, because the API refuses every request from a sign-in that has not cleared one. So the mailbox on its own can never strip the factor: it cannot get you a session in the first place. The challenge on top of that is a fresh proof at the moment of the change.
Requiring it across a workspace
A workspace can insist on it. When the requirement is on, every member of that workspace who has not turned the factor on for their own account is refused everything inside that workspace, and stays refused until they enroll. It is off by default, it is per workspace, and it is not the same as enrolling anyone: it cannot switch the factor on for a member, it cannot read their seed, and it cannot stop them switching their own factor off again.
Turning it on
The switch lives on the workspace Settings page, and the endpoint behind it is POST /internal/workspaces/require-two-factor with { "workspaceId": "...", "enabled": true }. Two things have to be true before it writes.
| Requirement | Detail |
|---|---|
The workspace:update permission | Held by the owner and admin roles, and grantable to a custom role. It is the same permission that renames the workspace and flips Virtual Envs |
| A step-up verification | It is a security control, so it asks for a code first, the same one hour elevation described in Step-up verification. An emailed code satisfies it |
You cannot require of others what you do not have yourself. Enabling is refused with 409 and Turn on two factor authentication for your own account before requiring it of everyone else. unless your own factor is already on. Without that check the write would lock the person making it out of the workspace they were trying to protect, in the same request.
What a blocked member sees
| Surface | What happens |
|---|---|
| The workspace itself | Every permission check inside it answers no, so reads and writes alike are refused. In the dashboard that arrives as the ordinary 401 any missing permission produces, and the reason is read off the workspace record rather than off the error |
| Their account | Untouched. They sign in exactly as before, and Account settings keep working, including the page that turns the factor on |
| Their other workspaces | Untouched. The requirement is a property of one workspace, never of the user, so a member blocked in one workspace works normally in the rest |
| Listing, resolving and switching workspaces | Still works. Those endpoints check membership rather than permissions, which is what lets the dashboard name the workspace and explain the block instead of going blank |
| Leaving the workspace | Still works. A member is never trapped in a workspace they cannot use |
| Accepting an invite to it | Still succeeds. Refusing the invite would strand it, so the member joins and then meets the same gate. The accept response carries twoFactorRequired: true so the dashboard can send them straight to enrollment |
| Their CLI tokens and API keys in that workspace | Refused with 403 and auth.workspace_two_factor_required |
Enrolling clears it. There is nothing to re-request and no admin to ask: the next request after the factor is on is answered normally, because the check reads the same twoFactorEnabled column the account page writes.
Machine credentials
Turning this on breaks CI for any member who has not enrolled. A CLI token and an ev_sk_ key are checked against their owner's account rather than against a session, so from the moment the requirement lands, every request either one makes into that workspace is refused with 403 auth.workspace_two_factor_required. Nothing is deleted or deactivated, and both start working again the moment their owner enrolls or the requirement is turned off. A CLI token is bound to the workspace it was approved for, so a blocked one is refused on every command, including envless workspace list. Check who owns the credentials your pipelines use before you switch this on.
Turning it off
Turning the requirement off stays possible for someone it is currently blocking. That one path checks your permission with the requirement deliberately skipped, so a workspace whose admins have all lost their authenticator is recoverable rather than dead forever. It still asks for a step-up code, which an emailed code answers. Access returns on the very next request, with nothing to re-enable and nobody to re-invite.
Every other member of the workspace is emailed when the requirement is turned on, and again when it is turned off. The person who flipped it is not mailed. It is a security notice rather than workspace news, so it is not one of the switches on Preferences and cannot be turned off. Flipping the setting to the value it already holds answers 200 with No changes detected. and mails nobody.
Your own factor stays yours. You can turn it off from Account settings while a workspace requires it, and nothing refuses the write. The only consequence is that the workspace stops answering until you turn it back on. An admin can close a workspace to you; they cannot reach into your account settings.
What the second factor does not cover
Be clear about the edges of this. Two-factor authentication guards interactive sign-in to the dashboard and the API endpoints behind it. It does not reach any of the following.
| Not covered | Why, and what changed instead |
|---|---|
CLI tokens (env_cli_...) | Not asked for the sign-in factor: a token has no sign-in. It is asked for a step-up code before a guarded command, exactly like a browser session, and envless prompts for it in the terminal; an authenticator or recovery code works there too. Minting a token is guarded as well: approving a device from the dashboard refuses CLI-token callers outright and requires a verification code. A token still lives 90 days. The one thing that stops a live token outright is the workspace requirement above, which refuses it on the owner's account rather than on the session |
API keys (ev_sk_...) | Same reason. Creating one requires a verification code, and the key itself then authenticates the public API with no second factor and no step-up on any endpoint. It is still refused with 403 auth.workspace_two_factor_required while its workspace requires the factor and its owner has not enrolled |
| The workspace passphrase | A different secret entirely. It never reaches Envless, and two-factor protects account access, never the ciphertext |
| Enrolling your teammates | A workspace can now require the factor of every member, covered above, but requiring is not enrolling. Nobody can turn the factor on for someone else, scan their QR code, see their seed or read their recovery codes. All the requirement does is close one workspace to the members who have not done it themselves |
A CLI token minted before you enrolled keeps working for the rest of its 90 days, second factor or not, unless the workspace it is bound to requires the factor. And once it expires, renewing it needs a dashboard session that can pass the factor and the verification code, so do not enroll on a device you are about to lose access to and expect the terminal to carry on indefinitely.
How it is enforced
This is the part worth reading if you are reviewing the security model rather than using it.
The gate is in the API, not the dashboard. Every authenticated request carrying a Firebase ID token is checked before the route runs: if the account has two-factor on and this sign-in has no verification recorded, the request is refused. A screen in the dashboard would have been decorative. Requesting an emailed sign-in code hands the underlying sign-in link back to an unauthenticated caller, and the ID token is then minted by Google rather than by Envless, so anyone willing to use curl would walk straight past a check that lived only in the browser.
The workspace requirement is a second, narrower gate. The account gate above already refuses every request from a sign-in that has not satisfied its factor, so any member whose factor is on has proved it for this sign-in. The workspace layer therefore only has to ask whether they have the factor at all, which it does inside the single row lookup every workspace endpoint already runs to resolve permissions. That is why it covers every controller that resolves a permission without one of them being changed, and why the only endpoint that opts out is the switch itself. CLI tokens and API keys never reach the account gate, so they are checked separately, in their own middleware, against the owner's account record.
Verification is bound to the sign-in, not the session. Firebase stamps each ID token with an auth_time claim, the moment that sign-in actually happened, and it stays put across the hourly token refreshes. A successful verification writes a row keyed by user and auth_time. That is why a verified browser is not asked again an hour later, and why a fresh sign-in is asked immediately: it has a new auth_time and no row.
| Detail | Value |
|---|---|
| Refusal | 403 with error code auth.two_factor_required |
Why not 401 | The dashboard reads 401 as a dead session and signs you out, which would loop forever against a gate you are trying to clear |
| Session row | Keyed on user and auth_time, expires after 30 days, and an hourly job clears expired ones |
| Endpoints outside the gate | internal/users/auth, so the dashboard can be told it must ask; internal/two-factor/status; and internal/two-factor/verify-login, the endpoint that clears the gate |
| Workspace refusal | 403 with auth.workspace_two_factor_required for a CLI token or an API key, because those are checked in middleware. Inside the dashboard it is the endpoint's own 401, because the workspace check is folded into the permission lookup |
| Everything else | Gated, including the endpoints that turn the factor off, so the factor cannot be removed from behind it |
The endpoints are POST /internal/two-factor/status, /enroll, /confirm, /disable, /verify-login and /recovery-codes. Enrollment is deliberately two calls: /enroll writes a pending seed and does not switch anything on, and /confirm is what flips the flag, only after both the app code and the account challenge check out. /disable and /recovery-codes take no code of their own, the account challenge is the proof.
Related
Signing in
The three ways into the dashboard, and what one factor buys you without this.
Step-up verification
The in-app re-verification, its scopes, and which factors satisfy it.
Unlocking the dashboard
The workspace passphrase, which two-factor does not touch.
Account and preferences
The rest of the Account page, including connected providers and deletion.