Skip to content

Sign in with Viremail for developers

Let people sign in to your app with their Viremail account. It is standard OpenID Connect, so any library works, and our SDKs make it a few lines.

Overview

Sign in with Viremail is an OpenID Connect provider. Your app sends the person to Viremail, they sign in and choose what to share, and Viremail sends them back with a code. Your server swaps the code for an ID token that says who they are.

  • The issuer is https://viremail.com. Everything else is in discovery.
  • The flow is the authorisation code flow with PKCE (S256). There is no implicit or password flow.
  • ID tokens are signed with ES256 by default, or RS256 if you choose it for your app.
  • Each developer organisation gets its own sub for a person (pairwise), so companies can’t join their data on it.
  • This is identity only. Tokens open nothing in the person’s mail, files or calendar.

Quick start

About five minutes, start to finish.

  1. Open the developer portal, create an organisation, then create an app. Choose its type: web, single-page, native or server.
  2. Add a callback address, such as http://localhost:3000/auth/callback. In development any port on localhost works.
  3. Copy the client ID (it starts vma_) and, for web and server apps, the client secret (vms_). The secret is shown once, so keep it somewhere safe.
  4. Add the code below, sign in with your own account, and you are done. Up to 100 people can use the app while it is in development.

A server app with Node and Express

Terminal
npm install @viremail/auth-node express
server.js
import express from 'express';
import { createViremailAuth, viremailExpress, signInButtonHtml, BUTTON_CSS } from '@viremail/auth-node';

const auth = createViremailAuth({
  clientId: process.env.VIREMAIL_CLIENT_ID,
  clientSecret: process.env.VIREMAIL_CLIENT_SECRET,
  redirectUri: 'http://localhost:3000/callback',
  postLogoutRedirectUri: 'http://localhost:3000/',
  scope: 'openid profile email',
});

const app = express();
// Adds /login, /callback, /logout and /backchannel-logout, and req.viremail on every request.
// Sessions live in an encrypted cookie (SESSION_SECRET: at least 32 characters).
const viremail = viremailExpress(auth, { secret: process.env.SESSION_SECRET });
app.use(viremail);

app.get('/', (req, res) => {
  if (!req.viremail) return res.send(`<style>${BUTTON_CSS}</style><form action="/login">${signInButtonHtml({ type: 'submit' })}</form>`);
  res.send(`Hello ${req.viremail.user.name}`); // escape it in a real app
});

// Only for people who are signed in. Use user.sub as their ID, never the email.
app.get('/account', viremail.requireAuth(), (req, res) => res.json(req.viremail.user));

app.listen(3000);

Prefer to handle it yourself? createAuthRequest() gives you the address plus the state, nonce and verifier to keep, and handleCallback(url, { state, nonce, codeVerifier }) checks the answer and returns the tokens and verified claims.

A single-page app in the browser

Terminal
npm install @viremail/auth-js
app.js
import { createViremailClient } from '@viremail/auth-js';

const viremail = createViremailClient({
  clientId: 'vma_your_client_id',
  redirectUri: 'http://localhost:5173/callback',
  scope: 'openid profile email',
});

// On your sign-in button:
document.querySelector('#signin').addEventListener('click', () => viremail.signInWithRedirect());

// On the /callback page:
if (location.pathname === '/callback') {
  const { user } = await viremail.handleRedirectCallback();
  console.log('Signed in as', user.name);
}

The packages are coming to npm and are not published yet. Until then, install them from the Viremail repository or use any OpenID Connect library with the endpoints below. A complete example is in examples/express-quickstart.

Endpoints

EndpointAddress
Discoveryhttps://viremail.com/.well-known/openid-configuration
Authorisehttps://viremail.com/oauth/authorize
Tokenhttps://viremail.com/oauth/token
Userinfohttps://viremail.com/oauth/userinfo
JWKShttps://viremail.com/.well-known/jwks.json
Revokehttps://viremail.com/oauth/revoke
Introspecthttps://viremail.com/oauth/introspect
Logouthttps://viremail.com/oauth/logout

Discovery

GET /.well-known/openid-configuration returns everything a library needs. It allows any origin, so browsers can read it too.

Response, shortened
{
  "issuer": "https://viremail.com",
  "authorization_endpoint": "https://viremail.com/oauth/authorize",
  "token_endpoint": "https://viremail.com/oauth/token",
  "userinfo_endpoint": "https://viremail.com/oauth/userinfo",
  "jwks_uri": "https://viremail.com/.well-known/jwks.json",
  "revocation_endpoint": "https://viremail.com/oauth/revoke",
  "introspection_endpoint": "https://viremail.com/oauth/introspect",
  "end_session_endpoint": "https://viremail.com/oauth/logout",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "subject_types_supported": ["pairwise", "public"],
  "id_token_signing_alg_values_supported": ["ES256", "RS256"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post", "private_key_jwt"],
  "scopes_supported": ["openid", "profile", "email", "business", "offline_access"],
  "backchannel_logout_supported": true,
  "frontchannel_logout_supported": true,
  "authorization_response_iss_parameter_supported": true
}

Authorise

Send the person’s browser here with a GET request.

ParameterWhat to send
response_typecode, always.
client_idYour client ID.
redirect_uriOne of your callback addresses, matched exactly.
scopeMust include openid. Unknown scopes are ignored.
stateA random value you check on the way back. Up to 1000 characters.
nonceA random value that comes back in the ID token. Up to 500 characters.
code_challengeThe PKCE challenge, with code_challenge_method=S256. Required for single-page and native apps.
promptOptional: none, login, consent or select_account.
max_ageOptional: ask the person to sign in again if they last did longer ago than this, in seconds.
login_hintOptional: the email address of the account to use.
id_token_hintOptional: an ID token you were given before, to pick the same person.
acr_valuesOptional: urn:viremail:loa:2 to ask for two-step or a passkey the device unlocks. People whose sign-in was a password alone confirm with their second step first.
Request
GET https://viremail.com/oauth/authorize?response_type=code
  &client_id=vma_your_client_id
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback
  &scope=openid%20profile%20email
  &state=Xq3v9...&nonce=n-0S6_WzA2Mj
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
Back at your callback
https://app.example.com/auth/callback?code=...&state=Xq3v9...&iss=https%3A%2F%2Fviremail.com

Check state is yours and iss is https://viremail.com. If something goes wrong after your callback address is checked, the person comes back with error, error_description, state and iss instead.

Token

A POST with a form body. It allows any origin, so single-page apps can call it. Codes last one minute and work once.

Request
curl https://viremail.com/oauth/token \
  -u "vma_your_client_id:vms_your_client_secret" \
  -d grant_type=authorization_code \
  -d code=THE_CODE \
  -d redirect_uri=https://app.example.com/auth/callback \
  -d code_verifier=THE_PKCE_VERIFIER
Response
{
  "access_token": "vmo_at_...",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "openid profile email offline_access",
  "id_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6Ii4uLiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "vmo_rt_..."
}

A refresh token comes only with offline_access. Use it with grant_type=refresh_token, optionally with a narrower scope. Each refresh gives you a new refresh token; using an old one again ends that sign-in everywhere, so store the new one every time.

Refresh
curl https://viremail.com/oauth/token \
  -u "vma_your_client_id:vms_your_client_secret" \
  -d grant_type=refresh_token \
  -d refresh_token=vmo_rt_...

Access tokens are opaque (vmo_at_...) unless you choose JWT access tokens for your app, which are typed at+jwt with the userinfo address as their audience.

Userinfo

GET or POST with the access token in the Authorization header only. It returns sub and the claims for the granted scopes.

Request
curl https://viremail.com/oauth/userinfo -H "Authorization: Bearer vmo_at_..."
Response
{
  "sub": "8kGm2pQ7rT1xVb4nYc9sLwQe3ZfH0jUa",
  "name": "Alex Morgan",
  "picture": "https://viremail.com/oauth/picture/...",
  "locale": "en-GB",
  "zoneinfo": "Europe/London",
  "updated_at": 1791100800,
  "email": "[email protected]",
  "email_verified": true
}

JWKS

The public keys for checking ID token signatures. Pick the key by the token’s kid, and fetch the set again when you see a kid you don’t know, because keys rotate.

Revoke

RFC 7009. Send an access or refresh token with the same client authentication as the token endpoint (public apps send client_id). It always answers 200.

Request
curl https://viremail.com/oauth/revoke \
  -u "vma_your_client_id:vms_your_client_secret" \
  -d token=vmo_rt_...

Introspect

RFC 7662, for web and server apps only. A token that belongs to another app comes back as { "active": false }.

Request
curl https://viremail.com/oauth/introspect \
  -u "vma_your_client_id:vms_your_client_secret" \
  -d token=vmo_at_...

Logout

See Logout.

Scopes and claims

Ask only for what you need. You choose the scopes your app may ask for in the portal; asking for more gives invalid_scope.

ScopeWhat it isClaims
openidRequired. Signs the person in.sub and the ID token claims below
profileName and photo, language and time zone.name, picture (an https address, may be missing), locale, zoneinfo, updated_at
emailThe person’s Viremail address.email, email_verified (always true)
businessTheir business on Viremail and their role in it. The person can untick it.business: { id, name, role }, with an id private to your organisation
offline_accessA refresh token, to keep the person signed in.No claims

The ID token

A JWT with kid and alg in its header, and these claims, plus the claims for the granted scopes.

ClaimValue
isshttps://viremail.com
subThe person’s ID for your organisation. Use this as your key.
audYour client ID, as a string.
iat, expIssued at, and one hour later.
auth_timeWhen the person last signed in to Viremail.
nonceThe nonce you sent. Refreshed ID tokens have none.
amr["pwd"], ["pwd","otp","mfa"] for two-step, or ["hwk","user","mfa"] for a passkey the device unlocked with a PIN, fingerprint or face (["hwk"] without that check)
acrurn:viremail:loa:1 (a password) or urn:viremail:loa:2 (two-step, or a passkey the device unlocked)
at_hashThe left half of the SHA-256 of the access token, base64url.
sidThe Viremail session, for logout.
Example claims
{
  "iss": "https://viremail.com",
  "sub": "8kGm2pQ7rT1xVb4nYc9sLwQe3ZfH0jUa",
  "aud": "vma_your_client_id",
  "iat": 1791100800,
  "exp": 1791104400,
  "auth_time": 1791100790,
  "nonce": "n-0S6_WzA2Mj",
  "amr": ["hwk", "user", "mfa"],
  "acr": "urn:viremail:loa:2",
  "sid": "...",
  "name": "Alex Morgan",
  "email": "[email protected]",
  "email_verified": true,
  "business": { "id": "b_9fQ...", "name": "Bright Labs", "role": "admin" }
}

App types and client authentication

TypeForSecretSign-in
WebA website with its own server, such as Express, Next.js or Django.YesCode with PKCE, secret or key at the token endpoint
ServerA back end with no pages of its own that still signs people in.YesCode with PKCE, secret or key at the token endpoint
Single-pageAn app that runs only in the browser.NoCode with PKCE, client_id only
NativeA phone or desktop app.NoCode with PKCE, client_id only; loopback or your own scheme for callbacks

PKCE

Always use PKCE with S256. Single-page and native apps must. Web and server apps may leave it out only if they send a nonce, but we recommend it for every app.

Client authentication

  • client_secret_basic: the client ID and secret in an HTTP Basic Authorization header. The default.
  • client_secret_post: client_id and client_secret in the form body.
  • private_key_jwt: no shared secret. Register a public key (ES256 or RS256) in the portal and sign a short JWT for each request, with iss and sub set to your client ID, aud set to the token endpoint or the issuer, an exp at most 10 minutes away and a jti you never reuse. Send it as client_assertion with client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer.
  • none: single-page and native apps, which send only client_id.

When you rotate a secret in the portal, the old one keeps working for 24 hours so you can deploy the new one.

Development and live

In development

A new app is in development. Only your organisation’s members and the testers you add can sign in, up to 100 people in all. Callbacks on http://localhost, 127.0.0.1 or [::1] work on any port. The consent screen tells people the app is in development.

Going live

  1. Add your domain in the portal and prove you own it, either way:
    • a DNS TXT record on _viremail-verify.yourdomain.com with the value viremail-verification=YOUR_TOKEN, or
    • a file at https://yourdomain.com/.well-known/viremail-verification.txt that contains the token.
  2. Add a logo, your website, a privacy policy address and a terms address.
  3. Make every callback and sign-out address https and on your verified domain. Native apps use a scheme from your domain, such as com.yourdomain.app:/callback.
  4. Tick the checklist: you own the domain, name and logo; your privacy policy covers what you get; you ask only for what you need; secrets stay on your servers; you use the official button.
  5. Send it for review. Viremail checks it and, once approved, the app is live for everyone and shows as Verified.

Once live, a new name or logo waits for review, and people keep seeing the approved ones until then. To change the domain, move the app back to development first.

Button guidelines

Use the official button from the SDKs, as it is. People trust it because it always looks the same.

  • Dark, on light backgrounds. The default.
  • Light, on dark backgrounds or photos.
  • Outline, next to other outlined sign-in buttons.
Small, 36 pixels high
Medium, 44 pixels high
Large, 52 pixels high
  • Labels: “Sign in with Viremail”, “Continue with Viremail”, “Sign up with Viremail”. Use the one that fits the page.
  • Shapes: rounded (the default) or pill, to match your other buttons.
  • Smallest size: Small, 36 pixels high. Keep the whole label; don’t shorten it.
  • Clear space: at least half the button’s height on every side, free of other text and images.
  • Give it the same size and weight as the other sign-in buttons on the page.

Do

  • Use the button the SDK draws, in one of the three themes.
  • Pick the theme that stands out from your background.
  • Tell people what you will do with their details.

Don’t

  • Change the colours, the mark, the font or the corners.
  • Use the Viremail mark on its own as a button, or in your app’s logo.
  • Say or suggest Viremail made, owns or recommends your app.

SDK reference

The packages are coming to npm and are not published yet. The names below are the ones they will have.

@viremail/auth-js, for the browser

FunctionWhat it does
createViremailClient(options)Makes a client with your clientId, redirectUri and scope.
signInWithRedirect()Sends the person to Viremail, with state, nonce and PKCE.
handleRedirectCallback()On your callback page: checks state and iss, swaps the code, verifies the ID token, and returns the user.
signInWithPopup()Signs in in a small window and keeps your page where it is. Call it from a click.
handlePopupCallback()On the callback page the popup opens.
getUser()The signed-in person’s claims, or null.
fetchUserInfo()Fresh claims from the userinfo endpoint.
getAccessToken()A current access token, refreshed when needed. Kept in memory unless you choose otherwise.
signOut()Forgets the tokens and, if you ask, signs out at Viremail too.
renderSignInButton(element, options)Draws the official button into an element. Options: theme, size, shape (rounded or pill) and text (signin, continue or signup).
signInButtonHtml(options)The official button as an HTML string, for server-rendered pages.

@viremail/auth-node, for your server

FunctionWhat it does
createViremailAuth(options)Makes a client with your clientId, secret or private key, and redirectUri.
createAuthRequest(options)Returns { url, state, nonce, codeVerifier }: send the person to url and keep the rest for the callback.
handleCallback(url, { state, nonce, codeVerifier })Checks state and iss, swaps the code and verifies the ID token. Returns { tokens, claims }.
verifyIdToken(token, options)Checks signature, iss, aud, exp and nonce.
refresh(refreshToken)New tokens. Store the new refresh token.
userinfo(accessToken)Claims from the userinfo endpoint.
revoke(token)Revokes an access or refresh token.
introspect(token)Whether a token is active, and for whom.
logoutUrl(options)The address for RP-initiated logout.
verifyLogoutToken(token)Checks a back-channel logout token.
sealSession(data, secret) and unsealSession(value, secret)Encrypts a session for a cookie, and opens it again.
viremailExpress(auth, options)Express middleware: /login, /callback, /logout and /backchannel-logout, req.viremail, and requireAuth().
nextHandlers(auth, options)The same for Next.js App Router route handlers, with getSession(request).

@viremail/auth-react, for React

ExportWhat it does
<ViremailAuthProvider>Wraps your app with a client from @viremail/auth-js.
useViremailAuth()The person, whether they are signed in, and signIn and signOut.
<SignInWithViremailButton>The official button, with theme, size, shape and text.
React
import { ViremailAuthProvider, useViremailAuth, SignInWithViremailButton } from '@viremail/auth-react';

function Account() {
  const { user, signOut } = useViremailAuth();
  if (!user) return <SignInWithViremailButton theme="dark" />;
  return <button onClick={() => signOut()}>Sign out {user.name}</button>;
}

export default function App() {
  return (
    <ViremailAuthProvider clientId="vma_your_client_id" redirectUri="http://localhost:5173/callback">
      <Account />
    </ViremailAuthProvider>
  );
}

Logout

RP-initiated logout

Send the browser to the logout endpoint. Register post_logout_redirect_uri exactly as a sign-out address first. Viremail asks the person whether to sign out of Viremail too, then sends them back with your state.

Request
GET https://viremail.com/oauth/logout?id_token_hint=eyJ...
  &post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Fsigned-out
  &state=af0ifjsldkj
  &client_id=vma_your_client_id

Back-channel logout

Set a back-channel address in the portal. When the person signs out of Viremail or removes your app, Viremail POSTs a form with logout_token: a JWT typed logout+jwt with iss, aud (your client ID), iat, exp, jti, sub, sid and an events claim of {"http://schemas.openid.net/event/backchannel-logout": {}}. It never has a nonce. Verify it like an ID token, then end every session with that sid or sub.

Front-channel logout

Set a front-channel address and Viremail loads it in a hidden frame with ?iss= and &sid=. Clear the session for that sid. Browsers that block third-party cookies may stop this working, so prefer back-channel.

Security best practices

  • Always use PKCE with S256, and a fresh state for every sign-in. Check state before anything else.
  • Check iss on the callback equals https://viremail.com.
  • Verify every ID token: the signature against the JWKS, iss, aud equal to your client ID, exp, and nonce equal to the one you sent.
  • Keep client secrets and private keys on your server. Never put them in a browser bundle, a mobile app or a public repository.
  • In the browser, keep tokens in memory, not in localStorage. On a server, keep them in an encrypted, httpOnly cookie or your session store.
  • Keep your own sessions short, and refresh rather than keeping long-lived access tokens.
  • Rotate secrets now and then, and at once if one may have leaked. The old one works for 24 hours.
  • Handle back-channel logout, so signing out of Viremail or removing your app signs the person out of yours.
  • Never use the email address as the key for a person: it can change. Use sub.
  • sub is pairwise: it is the same for all apps in your organisation and different for every other organisation. Don’t try to match people with another company’s data.

Errors

Errors from the token, revoke and introspect endpoints are JSON: { "error": "...", "error_description": "..." }. Errors from the authorise endpoint come back to your callback as query parameters. Show people a plain message and log the description.

ErrorWhereWhat it means
invalid_requestAuthorise, token, revokeA parameter is missing, repeated or not valid. error_description says which.
invalid_scopeAuthorise, tokenNo openid, or a scope your app is not set up for. Add it in the portal first.
unsupported_response_typeAuthoriseOnly response_type=code is supported.
access_deniedAuthoriseThe person said no, or this account can’t use an app in development.
login_requiredAuthorise (prompt=none)Nobody is signed in, or the person must sign in again (max_age).
consent_requiredAuthorise (prompt=none)The person has not allowed what you asked for yet.
account_selection_requiredAuthorise (prompt=none)More than one account is signed in. Ask again without prompt=none.
invalid_clientToken, revoke, introspect (401)Client authentication failed: wrong secret, a bad assertion, or an unknown client.
invalid_grantTokenThe code or refresh token is not valid, expired, already used, issued to another app, or the PKCE verifier or redirect_uri does not match.
unsupported_grant_typeTokenUse authorization_code or refresh_token.
invalid_tokenUserinfo (401)The access token is not valid, has expired or was revoked.
insufficient_scopeUserinfo (403)The access token has no openid scope.
temporarily_unavailableAny (503)Sign in with Viremail is paused on this server. Try again later.
server_errorAny (500)Something went wrong on our side. Try again.

Questions

Can I use my own OpenID Connect library?
Yes. Point it at https://viremail.com as the issuer and it reads the rest from discovery. The SDKs are a convenience, not a requirement.
Why is sub different from what another company sees?
IDs are pairwise: each developer organisation gets its own ID for a person. All apps in your organisation see the same sub, so you can link them.
Can I get someone’s email without asking for it?
No. Ask for the email scope. The person sees it on the consent screen.
How long do tokens last?
Codes last one minute, access tokens 15 minutes and ID tokens an hour. Refresh tokens last 30 days without use and a year at most, and change every time you use one.
What happens when someone removes my app?
Their tokens stop working and refresh fails with invalid_grant. Send them through sign-in again.
Do I need a business account to build an app?
No. Any Viremail account can create a developer organisation.

Planned

Scopes that let an app use parts of a person’s account through an API, such as reading their calendars or free time, are designed but not available yet. Each will need the person’s say-so on the consent screen and a review before going live. Today Sign in with Viremail is for signing people in only.