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
subfor 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.
- Open the developer portal, create an organisation, then create an app. Choose its type: web, single-page, native or server.
- Add a callback address, such as
http://localhost:3000/auth/callback. In development any port on localhost works. - 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. - 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
npm install @viremail/auth-node expressimport 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
npm install @viremail/auth-jsimport { 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
| Endpoint | Address |
|---|---|
| Discovery | https://viremail.com/.well-known/openid-configuration |
| Authorise | https://viremail.com/oauth/authorize |
| Token | https://viremail.com/oauth/token |
| Userinfo | https://viremail.com/oauth/userinfo |
| JWKS | https://viremail.com/.well-known/jwks.json |
| Revoke | https://viremail.com/oauth/revoke |
| Introspect | https://viremail.com/oauth/introspect |
| Logout | https://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.
{
"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.
| Parameter | What to send |
|---|---|
response_type | code, always. |
client_id | Your client ID. |
redirect_uri | One of your callback addresses, matched exactly. |
scope | Must include openid. Unknown scopes are ignored. |
state | A random value you check on the way back. Up to 1000 characters. |
nonce | A random value that comes back in the ID token. Up to 500 characters. |
code_challenge | The PKCE challenge, with code_challenge_method=S256. Required for single-page and native apps. |
prompt | Optional: none, login, consent or select_account. |
max_age | Optional: ask the person to sign in again if they last did longer ago than this, in seconds. |
login_hint | Optional: the email address of the account to use. |
id_token_hint | Optional: an ID token you were given before, to pick the same person. |
acr_values | Optional: 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. |
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=S256https://app.example.com/auth/callback?code=...&state=Xq3v9...&iss=https%3A%2F%2Fviremail.comCheck 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.
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{
"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.
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.
curl https://viremail.com/oauth/userinfo -H "Authorization: Bearer vmo_at_..."{
"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.
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 }.
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.
| Scope | What it is | Claims |
|---|---|---|
openid | Required. Signs the person in. | sub and the ID token claims below |
profile | Name and photo, language and time zone. | name, picture (an https address, may be missing), locale, zoneinfo, updated_at |
email | The person’s Viremail address. | email, email_verified (always true) |
business | Their 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_access | A 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.
| Claim | Value |
|---|---|
iss | https://viremail.com |
sub | The person’s ID for your organisation. Use this as your key. |
aud | Your client ID, as a string. |
iat, exp | Issued at, and one hour later. |
auth_time | When the person last signed in to Viremail. |
nonce | The 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) |
acr | urn:viremail:loa:1 (a password) or urn:viremail:loa:2 (two-step, or a passkey the device unlocked) |
at_hash | The left half of the SHA-256 of the access token, base64url. |
sid | The Viremail session, for logout. |
{
"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
| Type | For | Secret | Sign-in |
|---|---|---|---|
| Web | A website with its own server, such as Express, Next.js or Django. | Yes | Code with PKCE, secret or key at the token endpoint |
| Server | A back end with no pages of its own that still signs people in. | Yes | Code with PKCE, secret or key at the token endpoint |
| Single-page | An app that runs only in the browser. | No | Code with PKCE, client_id only |
| Native | A phone or desktop app. | No | Code 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 BasicAuthorizationheader. The default.client_secret_post:client_idandclient_secretin 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, withissandsubset to your client ID,audset to the token endpoint or the issuer, anexpat most 10 minutes away and ajtiyou never reuse. Send it asclient_assertionwithclient_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer.none: single-page and native apps, which send onlyclient_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
- Add your domain in the portal and prove you own it, either way:
- a DNS TXT record on
_viremail-verify.yourdomain.comwith the valueviremail-verification=YOUR_TOKEN, or - a file at
https://yourdomain.com/.well-known/viremail-verification.txtthat contains the token.
- a DNS TXT record on
- Add a logo, your website, a privacy policy address and a terms address.
- 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. - 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.
- 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.
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
| Function | What 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
| Function | What 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
| Export | What 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. |
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.
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_idBack-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
statefor every sign-in. Checkstatebefore anything else. - Check
isson the callback equalshttps://viremail.com. - Verify every ID token: the signature against the JWKS,
iss,audequal to your client ID,exp, andnonceequal 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. subis 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.
| Error | Where | What it means |
|---|---|---|
invalid_request | Authorise, token, revoke | A parameter is missing, repeated or not valid. error_description says which. |
invalid_scope | Authorise, token | No openid, or a scope your app is not set up for. Add it in the portal first. |
unsupported_response_type | Authorise | Only response_type=code is supported. |
access_denied | Authorise | The person said no, or this account can’t use an app in development. |
login_required | Authorise (prompt=none) | Nobody is signed in, or the person must sign in again (max_age). |
consent_required | Authorise (prompt=none) | The person has not allowed what you asked for yet. |
account_selection_required | Authorise (prompt=none) | More than one account is signed in. Ask again without prompt=none. |
invalid_client | Token, revoke, introspect (401) | Client authentication failed: wrong secret, a bad assertion, or an unknown client. |
invalid_grant | Token | The 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_type | Token | Use authorization_code or refresh_token. |
invalid_token | Userinfo (401) | The access token is not valid, has expired or was revoked. |
insufficient_scope | Userinfo (403) | The access token has no openid scope. |
temporarily_unavailable | Any (503) | Sign in with Viremail is paused on this server. Try again later. |
server_error | Any (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.