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-configuration

There 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.

your app's environment
WS_CLIENT_ID=ws_xxxxxxxxxxxx
WS_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx
WS_ISSUER=https://id.wsmailpro.com
WS_REDIRECT_URI=https://yourapp.com/auth/ws/callback

2. 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.

GET /auth/ws
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.

GET /auth/ws/callback
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:

verifyIdToken
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

ScopeClaims you receive
openidsub — always included
emailemail, email_verified
profilename, picture
orgorg_id, org_name, org_role
ws.verifiedws_verified, ws_profession, ws_credential
offline_accessadds 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)   // REQUIRED

Refresh 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

ErrorWhat it means
access_deniedThe user pressed Cancel on the consent screen.
invalid_clientWrong client_id or client_secret, or the app is disabled.
invalid_grantCode or refresh token expired, already used, or revoked.
invalid_requestMissing 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

Issuerhttps://id.wsmailpro.com
Discoveryhttps://id.wsmailpro.com/.well-known/openid-configuration
Authorizationhttps://id.wsmailpro.com/oauth/authorize
Tokenhttps://id.wsmailpro.com/oauth/token
UserInfohttps://id.wsmailpro.com/oauth/userinfo
JWKShttps://id.wsmailpro.com/oauth/jwks.json
Revocationhttps://id.wsmailpro.com/oauth/revoke
Signing algorithmES256, rotated with overlap
PKCERequired (S256) for all clients
Grantsauthorization_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.

Ready to build?

Create a free account, then register your app under Developer.

Get started