Advanced

Authorization & Sessions

How VitNode manages public and admin sessions, HttpOnly cookies, device fingerprinting, and session expiration.

VitNode implements two isolated sessions: a public session for the community site and a separate admin session for the AdminCP. Both use opaque random tokens stored as SHA-256 hashes in PostgreSQL.

Quick start

Customize session parameters in apps/api/src/vitnode.api.config.ts:

apps/api/src/vitnode.api.config.ts
import { buildApiConfig } from "@vitnode/core/vitnode.config"

export const vitNodeApiConfig = buildApiConfig({
  authorization: {
    cookieExpires: 1000 * 60 * 60 * 24 * 30, // 30 days for public users
    adminCookieExpires: 1000 * 60 * 30, // sign staff out after 30 idle minutes
    cookieDomain: ".yourdomain.com", // Optional cross-subdomain sharing
  },
})

The Two-Session Model

Session TypeCookie NameDefault LifetimeStorage TablePurpose
Public Sessionvitnode_auth90 dayscore_sessionsFrontend member authentication
Admin Sessionvitnode_auth_admin1 hour of inactivitycore_admin_sessionsHigh-privilege AdminCP access
Known Devicevitnode_device1 yearcore_sessions_known_devicesDevice authorization tracking

Session Isolation

Signing out of the AdminCP does not terminate the user's public session, and vice versa. An administrator compromised in a public context cannot access the AdminCP without re-authenticating with staff credentials - a password or a user-verified passkey, checked against live staff access. Neither a public session nor social SSO ever becomes an AdminCP session.


AdminCP Session Timeout

The AdminCP is the keys to the kingdom, so its session is deliberately short-lived. Staff are asked to sign in again when:

  • They go quiet for an hour. Every AdminCP request pushes the expiry an hour ahead, so an admin who keeps working is never interrupted. Leave the tab alone for an hour and it signs itself out and lands on the sign-in page, with a toast explaining why. Tune the window with adminCookieExpires.
  • They close every AdminCP tab. Open AdminCP tabs keep a heartbeat going. Open the AdminCP again after every tab was closed and VitNode ends the old session instead of letting it back in. A page reload, or opening one more AdminCP tab while another is still open, is not affected. This check is skipped when cookieDomain is set: a browser can only see tabs on its own origin, so an AdminCP tab open on another subdomain would look closed.
  • They close the browser. The vitnode_auth_admin cookie has no expiry date, so the browser drops it when it shuts down.

Leaving the AdminCP counts as closing it

Moving from the AdminCP to the public site in your only AdminCP tab stops the heartbeat. Come back within about 15 seconds and you are still signed in; any later and you sign in again. Open the public site in a new tab to keep your AdminCP session.


Security Guarantees

  • SHA-256 Token Storage: The raw token is stored only in the user's HttpOnly cookie. The database stores only its cryptographic hash.
  • Device Pinning: Tokens are bound to a verified device ID. Tokens copied to another device without the matching device cookie are rejected.
  • Fast 60-Second Caching: Active sessions are cached in Redis for up to 60 seconds, eliminating database query overhead on repeated requests.
  • Canonical Email Identity: Addresses are stored in their canonical form, so Gmail dot, googlemail.com and +tag variants cannot become a second account. See One Mailbox, One Account.

One Mailbox, One Account

jankowalski@gmail.com and jan.kowalski@gmail.com look like two addresses. Gmail ignores the dots, so they are one mailbox - and a site that treats them as two lets the same person hold two accounts, dodge a ban, or farm their own referral link.

VitNode stores the canonical form of an address, so those tricks collapse into a single account:

Typed at sign-upStored in core_users.email
Jan.Kowalski@Gmail.comjankowalski@gmail.com
jankowalski@googlemail.comjankowalski@gmail.com
jankowalski+shop@gmail.comjankowalski@gmail.com
piotr+news@outlook.compiotr@outlook.com
zofia.nowak@company.comzofia.nowak@company.com

The rules are per-provider, because they are only true per-provider:

  • Dots are removed for gmail.com and googlemail.com only. Everywhere else a dot is part of the mailbox, so jan.kowalski@company.com is left alone.
  • googlemail.com folds into gmail.com. Google treats them as one domain.
  • +tag suffixes are dropped for Gmail and for the providers that document sub-addressing: Outlook, Hotmail, Live, MSN, iCloud, Proton, Fastmail, GMX, Yandex and Zoho. Mail sent to the canonical address still lands in the same inbox.
  • Everything else is kept as typed, lowercased. A + can be a real character in a corporate address, so guessing there would lock people out.
  • Yahoo's - aliases are deliberately left alone, because jan-kowalski@yahoo.com is a perfectly ordinary name.

Where it applies

Sign-up, sign-in, password reset, the SSO callback and AdminCP user management all resolve an address the same way. Someone who registered with a password at jankowalski@gmail.com and later clicks Sign in with Google - which hands back jan.kowalski@gmail.com - lands on their own account instead of a second one.

Upgrading an existing install

The 20260906174535_canonicalize_user_emails migration rewrites stored addresses in place. A row whose canonical form is already taken by another row is left untouched, so nobody is locked out and no two accounts are silently merged. Run this before upgrading to see whether your install has such pairs:

SELECT
  replace(split_part(split_part(lower("email"), '@', 1), '+', 1), '.', '') AS mailbox,
  array_agg("email")
FROM "core_users"
WHERE lower("email") LIKE '%@gmail.com' OR lower("email") LIKE '%@googlemail.com'
GROUP BY 1
HAVING count(*) > 1;

authorization Options Reference

Prop

Type

Turning off password sign-in

Running a passkeys-only or social-login-only community? Switch passwords off:

apps/api/src/vitnode.api.config.ts
export const vitNodeApiConfig = buildApiConfig({
  authorization: {
    password: false,
  },
})

With passwords off:

  • /login hides the email + password form and Forgot password?. It keeps SSO buttons and Sign in with a passkey - if neither is configured, members see a "Signing in is unavailable" notice.
  • /register hides the sign-up form. New accounts can still come in through SSO; with no SSO provider, the page says registration is unavailable and the "Sign up" link on /login goes away.
  • /login/reset-password answers "not found".
  • The API refuses POST /users/sign_in (for members), /users/sign_up, /users/reset-password, /users/change-password and password-based SSO linking with 403.
  • A stored password no longer counts as a way into the account, so members can't remove their last passkey unless they have another passkey or a linked SSO provider.

The AdminCP sign-in at /admin keeps its email + password form, so switching member passwords off never locks your staff out. With passkeys enabled, staff can also use AdminCP passkey sign-in. Signing out works exactly as before.

Password hashes already in the database are left alone, so switching enabled back to true restores password sign-in for everyone who had one.

Learn More