Sign in with WS Mail
Let anyone with a WS Mail inbox sign into your app — one button that both signs them up and signs them in.
Let your AI build it
A complete brief — endpoints, the exact flow, verification rules and security constraints. Paste it into Claude Code, Cursor, or any coding assistant.
Or fetch it directly: curl https://id.wsmailpro.com/oauth/ai-prompt
Overview
WS Mail is a standard OpenID Connect provider, so any OIDC library works — or plain HTTP, as shown below. Point your library at the discovery document and it configures itself:
https://id.wsmailpro.com/.well-known/openid-configurationThere is no separate sign-up flow. The first time someone signs in, you receive their verified details and create the account then — the same way “Sign in with Google” works.
Verified email
Addresses are confirmed before we assert them.
Professional credentials
Know whether a user holds a live verified badge.
PKCE + 2FA signal
Mandatory PKCE; tokens tell you if 2FA was used.
1. Register your app
Sign up at wsmailpro.com, then open the admin console and go to Developer → Register an app. You'll need:
- Name and optionally a logo — shown on the consent screen.
- Redirect URI — where we send users back. Must be https (except
localhost), matched exactly. No wildcards, no trailing-slash mismatches.
You get a client_id and a client_secret.
The secret is shown once — it's stored hashed and cannot be recovered. Keep it server-side. Building a browser-only SPA or mobile app? Register a public client instead: no secret, PKCE alone. A secret shipped to users is not a secret.
WS_CLIENT_ID=ws_xxxxxxxxxxxx
WS_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx
WS_ISSUER=https://id.wsmailpro.com
WS_REDIRECT_URI=https://yourapp.com/auth/ws/callback2. Send the user to WS Mail
Generate a PKCE pair plus state and nonce, store them in a short-lived httpOnly cookie, and redirect. PKCE is required for every client, including ones with a secret.
import { randomBytes, createHash } from 'node:crypto'
const b64u = (b) => b.toString('base64url')
export async function GET(req, res) {
const state = b64u(randomBytes(24))
const nonce = b64u(randomBytes(24))
const codeVerifier = b64u(randomBytes(32))
const codeChallenge = b64u(createHash('sha256').update(codeVerifier).digest())
setCookie(res, 'ws_flow', JSON.stringify({ state, nonce, codeVerifier }), {
httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600, path: '/',
})
const url = new URL('https://id.wsmailpro.com/oauth/authorize')
url.searchParams.set('client_id', process.env.WS_CLIENT_ID)
url.searchParams.set('redirect_uri', process.env.WS_REDIRECT_URI)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', 'openid email profile ws.verified')
url.searchParams.set('state', state)
url.searchParams.set('nonce', nonce)
url.searchParams.set('code_challenge', codeChallenge)
url.searchParams.set('code_challenge_method', 'S256')
res.redirect(url.toString())
}3. Handle the callback
Check state, then exchange the code for tokens. Rejecting a state mismatch is what stops an attacker replaying their own authorization code into your user's session.
export async function GET(req, res) {
const { code, state, error } = req.query
if (error) return res.redirect('/sign-in?error=' + error)
const flow = JSON.parse(getCookie(req, 'ws_flow') ?? '{}')
clearCookie(res, 'ws_flow')
if (!state || state !== flow.state) throw new Error('state mismatch')
const basic = Buffer.from(
process.env.WS_CLIENT_ID + ':' + process.env.WS_CLIENT_SECRET
).toString('base64')
const r = await fetch('https://id.wsmailpro.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + basic,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.WS_REDIRECT_URI,
code_verifier: flow.codeVerifier,
}),
})
const tokens = await r.json()
if (!r.ok) throw new Error(tokens.error_description ?? tokens.error)
const claims = await verifyIdToken(tokens.id_token, flow.nonce)
const user = await findOrCreateUser(claims) // sign-up AND sign-in
await startSession(res, user)
res.redirect('/')
}Public clients omit the Authorization header and send client_id in the body instead — PKCE is the proof.
4. Verify the id_token
An unverified token is just a string somebody sent you. A JOSE library does this in one call (jose: jwtVerify with a remote JWKS). Without a dependency it's about twenty lines:
import { createPublicKey, verify } from 'node:crypto'
let jwks = []
export async function verifyIdToken(idToken, expectedNonce) {
const [h, p, s] = idToken.split('.')
const header = JSON.parse(Buffer.from(h, 'base64url').toString())
const claims = JSON.parse(Buffer.from(p, 'base64url').toString())
// Pin the algorithm. Trusting header.alg is the classic JWT confusion attack.
if (header.alg !== 'ES256') throw new Error('unexpected alg')
let jwk = jwks.find(k => k.kid === header.kid)
if (!jwk) { // unknown kid => key rotated; refetch
jwks = (await (await fetch('https://id.wsmailpro.com/oauth/jwks.json')).json()).keys
jwk = jwks.find(k => k.kid === header.kid)
if (!jwk) throw new Error('unknown signing key')
}
const ok = verify('sha256', Buffer.from(h + '.' + p),
{ key: createPublicKey({ key: jwk, format: 'jwk' }), dsaEncoding: 'ieee-p1363' },
Buffer.from(s, 'base64url'))
if (!ok) throw new Error('bad signature')
const now = Math.floor(Date.now() / 1000)
if (claims.iss !== 'https://id.wsmailpro.com') throw new Error('bad issuer')
if (claims.aud !== process.env.WS_CLIENT_ID) throw new Error('bad audience')
if (claims.exp <= now) throw new Error('expired')
if (claims.nonce !== expectedNonce) throw new Error('bad nonce')
return claims
}Cache the JWKS, but always refetch when you see an unknown kid — that's how key rotation reaches you without a redeploy.
5. Store the user — on sub, never on email
sub is permanent. An email address is not: when someone leaves an organisation their address can be reassigned, and an account keyed on email would hand their replacement the departed user's data.
async function findOrCreateUser(claims) {
const existing = await db.users.findOne({ ws_sub: claims.sub })
if (existing) return existing
if (!claims.email_verified) throw new Error('refusing unverified email')
return db.users.insert({
ws_sub: claims.sub, // the stable key — index this
email: claims.email, // display only
name: claims.name,
verified_professional: claims.ws_verified === true,
})
}Using Supabase?
Supabase Auth has a fixed provider list and cannot register a custom OIDC provider, so mint the session yourself after verifying. Your user ends up with a normal Supabase session on your project — your RLS, your auth.uid(), nothing shared with WS Mail.
// server — after verifying the id_token
let { data } = await supabaseAdmin.auth.admin.generateLink({
type: 'magiclink', email: claims.email,
})
if (!data?.properties?.hashed_token) { // first sign-in: create them
await supabaseAdmin.auth.admin.createUser({
email: claims.email,
email_confirm: true,
user_metadata: { full_name: claims.name, ws_sub: claims.sub },
})
;({ data } = await supabaseAdmin.auth.admin.generateLink({
type: 'magiclink', email: claims.email,
}))
}
res.redirect('/auth/callback#token_hash=' + data.properties.hashed_token)
// browser
await supabase.auth.verifyOtp({ type: 'email', token_hash })Scopes and claims
| Scope | Claims you receive |
|---|---|
| openid | sub — always included |
| email, email_verified | |
| profile | name, picture |
| org | org_id, org_name, org_role |
| ws.verified | ws_verified, ws_profession, ws_credential |
| offline_access | adds a refresh_token |
Request the least you need — every extra scope is another line the user has to approve. ws_verified reflects live credential status: a lapsed badge reports false.
claims.acr === 'urn:wsmail:2fa' tells you the user completed two-factor for this sign-in. WS Mail already refuses to issue a token unless a user with 2FA enabled has used it, so check this only if your app wants to require it regardless.
Sessions and refresh
Ask for offline_access and you'll receive a refresh_token.
const r = await fetch('https://id.wsmailpro.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + basic,
},
body: new URLSearchParams({
grant_type: 'refresh_token', refresh_token: stored,
}),
})
const next = await r.json()
await saveRefreshToken(next.refresh_token) // REQUIREDRefresh tokens rotate. Each refresh returns a new one and invalidates the old. If an already-used token is presented again we treat it as theft and revoke every session your app holds for that user. Always persist the new value, and never refresh from two places concurrently.
Errors
| Error | What it means |
|---|---|
| access_denied | The user pressed Cancel on the consent screen. |
| invalid_client | Wrong client_id or client_secret, or the app is disabled. |
| invalid_grant | Code or refresh token expired, already used, or revoked. |
| invalid_request | Missing PKCE, wrong redirect_uri, or a malformed request. |
Errors that occur before we can trust your redirect URI are rendered rather than redirected — that's deliberate, so this endpoint can't be used as an open redirector.
Reference
| Issuer | https://id.wsmailpro.com |
| Discovery | https://id.wsmailpro.com/.well-known/openid-configuration |
| Authorization | https://id.wsmailpro.com/oauth/authorize |
| Token | https://id.wsmailpro.com/oauth/token |
| UserInfo | https://id.wsmailpro.com/oauth/userinfo |
| JWKS | https://id.wsmailpro.com/oauth/jwks.json |
| Revocation | https://id.wsmailpro.com/oauth/revoke |
| Signing algorithm | ES256, rotated with overlap |
| PKCE | Required (S256) for all clients |
| Grants | authorization_code, refresh_token |
Users can review and revoke your app at any time from Settings → Connected apps in WS Mail. Revocation is immediate — the next refresh fails with invalid_grant.