Your account is one record that sits outside every workspace: a name, an avatar, one email address, and the sign-in providers linked to it. It lives on two dashboard pages, Account and Preferences. Nothing on either page touches encryption. No variable is read or written here, so no passphrase and no workspace key is involved.
Both pages save on an explicit Save button that stays disabled until something actually changed, and pressing Enter in the Name or Email field does the same thing as clicking it. Name and avatar go to one endpoint that reads only name and avatar; the email address is handled by a separate two-step flow and can never be written by a plain save.
What is on each page
| Page | Section | What it is |
|---|---|---|
| Account | Avatar | Upload or remove the image shown across the dashboard. Removing it falls back to an avatar generated from your account id |
| Account | Name | Your display name, required, 3 to 28 characters |
| Account | The address you sign in with. Changing it is the two-step flow below, and the field is read only while a provider is connected | |
| Account | Connected Accounts | Google and GitHub, each with a Connect or Disconnect button |
| Account | Two-Factor Authentication | Enrol an authenticator app, regenerate recovery codes, or turn the factor off. Off by default. See Two factor authentication |
| Account | Delete Account | Permanent deletion, gated on account age and a typed confirmation |
| Preferences | Hide emails | One switch that hides email addresses on your own dashboard |
| Preferences | Notifications | Six switches, one per family of notification email, all on by default. See Notification emails |
The account actions ask for a code
Seven actions on the Account page are guarded by step-up verification, and they are the only ones in Envless that carry an account scope rather than a workspace one:
- Requesting an email change
- Connecting a provider
- Disconnecting a provider
- Turning two factor authentication on
- Turning it off
- Regenerating your recovery codes
- Deleting the account
The account scope matters in one direction. An account elevation also satisfies any workspace-scoped check, so verifying here covers a key rotation five minutes later. A workspace elevation does not work the other way, so nothing you verified on the Keys page will get you through the Danger zone. Editing your name or avatar, uploading an image and every switch on Preferences are all unguarded.
You are asked at most once per hour across all seven. The dialog opens, takes the code, then reruns the action you clicked. With two factor authentication on it asks for your authenticator code and mails nothing.
Changing the email you sign in with
The account email is the address that receives your sign-in code, so it is never changed by a single click. Typing a new address and saving opens a request: Envless sends a verification link to the new address, and only when that link is opened does the address actually move. The old address is then sent a notice that it happened.
Request the change
Saving a changed address posts it to internal/users/request-email-change, trimmed and lower-cased. The endpoint is step-up guarded, so unless you verified in the last hour the dashboard asks for a code first. The address then has to be a valid one of at most 255 characters, different from your current one, and unclaimed both in the Envless database and in the authentication service. Any request you already had open is expired first, so there is only ever one live link.
Envless emails the new address
A token of 32 random bytes is generated, hex encoded, and only its SHA-256 hash is stored, so the link in the mail is the only copy of it. The mail goes to the new address with the subject Confirm your new email - Envless, and the link is the dashboard root with ?email-change-token=<token> on it. It expires 1 hour after it was minted.
Open the link
The dashboard reads that query parameter on whatever page it lands on, strips it back out of the URL immediately, and posts the token to internal/users/confirm-email-change. That endpoint carries no auth middleware: the token is the authority, so the link works in any browser, whether or not anyone is signed in there.
The swap, then the notice
Confirming claims the request (pending becomes applying), re-runs the provider and availability checks, writes the new address to the authentication service, and then to the database. If the database write does not land, the authentication service is put back to the old address and nothing moves. When both agree, the old address is emailed Your email address was changed - Envless.
The hint under the field, Pending. Confirm the link we sent to <address>, is held in memory only. Reloading the page clears it while the request is still perfectly live, so treat the mail in the new inbox as the record, not the hint.
What the request refuses
| Situation | Status | Message |
|---|---|---|
| Missing, not a string, or blank after trimming | 400 | Please enter a valid email address! |
| Not a valid address | 400 | Please enter a valid email address. |
| Longer than 255 characters | 400 | Email address should be less than 255 characters long. |
| Already your address | 400 | That is already your account email! |
| Google or GitHub connected | 409 | Disconnect Google and GitHub before changing your email! |
| Another Envless account, or another authentication record, holds it | 409 | That email is not available. Please use a different one! |
| A request is somehow already in flight | 409 | An email change is already in progress. Please check your inbox! |
| The authentication service could not be reached | 503 | We could not reach the authentication service. Please try again! |
| The verification email could not be sent | 500 | We could not send the verification email. Please try again! |
The in-progress 409 is a race, not the normal path: a new request expires your previous one before it inserts, and a unique index over active, undeleted requests per user is what answers if two requests land at the same instant. A request whose email fails to send is marked failed rather than left open.
What the confirmation refuses
| Situation | Status | Message |
|---|---|---|
| The token is missing or not a string | 400 | That link is invalid or has already been used. |
| The token matches no request | 404 | That link is invalid or has already been used. |
| The request already completed | 200 | Your email has already been changed. |
| Another confirmation is mid-flight on the same request | 409 | That link is already being used. |
| Past its hour, or no longer pending | 400 | That link has expired. Please request a new one. |
| A provider was connected since the request | 409 | Disconnect Google and GitHub before changing your email! |
| The address was taken since the request | 409 | That email is not available. Please use a different one! |
An already-completed request answers 200, not an error, so opening the same link twice is harmless. Every confirmation that gets as far as claiming the request increments a try counter on it, and that counter is what caps how many times the reconciliation cron below will hand the link back to you.
What follows the address
Once the swap lands, four things are updated with it: pending workspace invites addressed to the old address are re-pointed at the new one, the Polar billing customer's email, the Resend contact (its id is written back if it changed), and the cached user record. If any of that fails, the email change still stands. The request is marked propagated = false instead, and a cron retries it.
The reconciliation cron behind it
Two scheduled jobs stand behind the request table. reconcile-email-changes runs every 5 minutes and is the self-healing sweep; cleanup-email-change-requests runs hourly and is only garbage collection. The reconciler makes three passes, and the two that read rows take at most 50 of them per run:
Expire
Any request still pending past its expiresAt is moved to expired and deactivated. This is bookkeeping rather than enforcement: the confirm endpoint re-checks the expiry itself and only claims a request that is still pending and still in date, so a missed run cannot revive a dead link.
Rescue
A request stuck in applying for more than 5 minutes means a confirmation started and did not finish, which is the one state a user cannot get out of on their own. The sweep reads the real address from the authentication service and decides from that. If the service already holds the new address, it finishes the swap in the database, runs the propagation, marks the request completed and sends the notice to the old address. If the service still holds the old address, it puts the request back to pending so your original link works again. If it holds neither, the mismatch is logged and the request fails.
Re-propagate
Completed requests with propagated = false get the invite, Polar, Resend and cache fan-out retried, up to 5 attempts and only within 24 hours of the request settling. A request whose user has since moved to a different address again is marked propagated without doing any work, because it has been superseded.
The rescue pass refuses to hand a link back in four cases, and fails the request instead: it is already past its hour (marked expired), it has been tried 5 times, a Google or GitHub account has appeared on the record since the request was opened, or someone else has taken the new address in the meantime.
The two passes that touch a single account, the rescue and the re-propagation, take the same per-user advisory lock the confirm endpoint takes, so a duplicated or overlapping tick cannot apply the same change twice, and a locked row is skipped and picked up on the next run rather than forced. The expire pass needs no lock, because it is one conditional update that can only move a row that is still pending. The hourly cleanup soft-deletes completed, failed and expired requests once they are older than 30 days, which costs disk if it is missed and nothing else.
Email changes are blocked while a provider is connected
You cannot change your email while Google or GitHub is connected. The dashboard shows this by making the field read only and switching the section description to Disconnect Google and GitHub to change your email., and the rule is enforced three more times behind that: the request refuses with 409, the confirmation re-checks it at the moment of the swap, and the reconciler fails a rescued request outright if a provider appeared while the link was in flight.
The reason is the matching rule on the other side. A connected provider is only accepted when the provider account's email equals your Envless email, so allowing the two to drift apart would leave a linked account that can no longer be verified. The order to work in is therefore: disconnect both providers, change the email and confirm it, then connect the provider whose account uses the new address.
Connected accounts
Two providers exist, Google and GitHub, and neither is required. Signing in with an emailed code always works, which is why disconnecting both is allowed and why the disconnect dialog says so: You will no longer be able to sign in with <provider>, but you can still sign in with your email (<address>).
Connect opens a browser popup to link the provider, then posts to internal/users/connect-provider, which is step-up guarded. The server re-reads your providers from the authentication service and checks two things: that the link actually took, and that the provider account's email matches your Envless address. A mismatch is unlinked again server side and answered with 409 and Connect the <provider> account that uses <your address>. If the API refuses after the popup succeeded, the dashboard unlinks the provider again as well, so a failed connect does not leave a half-linked account behind.
| What happened | What you see |
|---|---|
| The provider account is linked to another Envless user | That account is already linked to another Envless user. |
| The popup was blocked | Your browser blocked the popup. Please allow popups and try again. |
| Your session is too old for the link | Please sign in again before connecting an account. |
| The provider is already linked in the authentication service | That account is already linked here. Reload the page and try again. |
| You closed the popup yourself | Nothing, the action is silently abandoned |
| The provider account uses a different address | Connect the <provider> account that uses <your address>. |
Disconnect asks for a plain confirmation (no typing) and a verification code, then posts to internal/users/disconnect-provider, which unlinks the provider in the authentication service and rewrites your address as verified so email sign-in keeps working. A provider that is already gone upstream is not an error: the local flag is healed and the response is 200 with <provider> is not connected. An authentication service that cannot be reached answers 503 and nothing is changed locally.
Hide emails
The first switch on Preferences is Hide emails, described in the dashboard as hiding email addresses across the dashboard. Saving it posts { "hideEmails": true | false } to internal/users/preferences, the same endpoint the notification switches use. Setting it to the value it already has answers 200 with No changes detected. rather than writing anything.
internal/users/preferences reads two keys, hideEmails and notifications, and only refuses with 400 and Invalid preferences payload. when neither is usable. A payload whose hideEmails is not a boolean is not an error on its own: the key is ignored and a valid notifications object alongside it still saves. Unknown keys inside notifications, and values that are not booleans, are dropped the same way.
It is stored on your user record, so it is not scoped to a workspace: it follows you into every workspace you open, and it is yours alone. Two surfaces read it today:
- The account button at the bottom of the sidebar drops the address line under your name.
- The Email field on the Account page renders 28 bullet characters instead of the address, and only while a provider is connected, which is the state where that field is read only anyway. The copy button beside it still copies the real address.
This is a display preference on your own screen, not access control. It does not mask addresses on the Team page, in invites, in emails, or in any API response, and it changes nothing for anyone else, because every account carries its own switch. It hides your address from someone reading over your shoulder, not from your workspace.
Deleting your account
Account deletion is dashboard-only and has no public API route. API overview lists it as deliberately dashboard-only and it is: the public API-key surface has exactly one account endpoint, GET /me, which returns your id, name, email and avatar alongside the key's workspace id and metadata, and writes nothing. Deletion lives on an internal endpoint, and that surface rejects an ev_sk_ key outright with 401 and error.code auth.unauthorized, whose message starts auth.api_key_not_accepted:. There is no public delete, no CLI command, and no SDK method. The button on the Account page is the way to trigger it.
Three gates stand in front of the button:
| Gate | Behavior |
|---|---|
| Account age | The account has to be older than 7 days. Below that the button is disabled with the tooltip Account must be older than 7 days to delete., and the endpoint refuses anyway with 400 and You cannot delete your account within 7 days of creating it. |
| Typed confirmation | The dialog asks you to type your account email exactly. It is trimmed and compared case-sensitively, and the Confirm button stays inert until it matches |
| Step-up verification | Deletion is an account-scoped step-up guarded action. Without a live account elevation the endpoint answers 403 with auth.step_up_required and This action needs a verification code to continue., and the dashboard opens the code dialog, then retries the deletion once you verify. The gate applies whether you hold workspaces or none, and an elevation earned inside a workspace does not satisfy it |
Confirming runs one pass and does all of this:
| Target | What happens |
|---|---|
| Your user record, provider record and user data record | Marked deleted |
| Sign-in codes issued to your address | Marked deleted |
| Your avatar file | Deleted from the CDN bucket |
| Workspaces you own | Deleted whole. Products, projects, environments, variables, product access grants, memberships, roles, API keys, CLI tokens, webhook endpoints, pending invites and the subscription record are all marked deleted, and any custom domain is moved to a removing state |
| Workspaces you only joined | Only your membership is removed, the workspace itself is untouched |
| Your Resend contact and your authentication-service account | Deleted |
| Your Polar customer record | Deleted, when the account has one |
Then the dashboard signs you out. Rows are marked deleted rather than dropped from the database, but nothing in the product reverses that: there is no restore, no grace period after the click, and no self-serve undo. The 7 day rule is the only delay anywhere in the flow.
There is no ownership transfer in Envless. A workspace you own is deleted along with your account, and everyone else on it loses access to every product, project and variable in it. If a teammate should keep the work, move it into a workspace they own before you delete, because nothing on this page hands a workspace over.
Deleting the account skips the guards that protect a workspace delete. Deleting a workspace on its own is refused while it still holds projects (Delete every project in this workspace before deleting it.), while it is younger than 7 days (You cannot delete a workspace within 7 days of creating it.), and when it is your last one, but account deletion calls the same delete directly, without those checks. Billing is closed either way: a workspace delete cancels that workspace's subscription, and an account delete removes your Polar customer record, which takes every subscription on it. Neither is refunded. See Plans and billing.