Login & Identity

Maillog hosts a standards-based identity provider (OIDC) that you can put your own logo on, on your own domain. This page documents everything a developer needs to connect software to it. Everything below is what the service actually does today.

What this is

You create end-user accounts under your team, and your applications let those users log in via OAuth 2.0 / OpenID Connect. Each account can carry a mailbox, calendar and address book on the Maillog platform, but none of that is required: you can use the identity provider purely for authentication.

The login screens live on their own host with their own security boundary (separate from the sending API), so a send-traffic spike never delays a login. By default everything runs on login.maillog.dev; with a verified domain the screens move to login.yourdomain.com in your branding.

Quickstart

Three values connect any OIDC library: a client ID, a client secret (confidential clients only), and the discovery URL.

# 1. Read the discovery document
curl https://login.maillog.dev/.well-known/openid-configuration

# 2. Register a client (in the dashboard, or via the admin API, below)
#    You receive: client_id, client_secret (shown once), redirect URIs.

# 3. Point your app at the issuer:
#    issuer: https://login.maillog.dev
#    authorization_endpoint: /authorize
#    token_endpoint: /token

Example with Auth.js/NextAuth — the same shape works for AppAuth, Spring Security, Passport, Ory, and every other OIDC library:

providers: [
  {
    id: "maillog",
    name: "Log in with your account",
    type: "oauth",
    issuer: "https://login.maillog.dev",
    clientId: process.env.MAILLOG_CLIENT_ID,
    clientSecret: process.env.MAILLOG_CLIENT_SECRET,
    authorization: { params: { scope: "openid profile email" } },
  },
]

The OAuth flow

Authorization code flow only. state is required. For public clients (mobile apps, SPAs), PKCE with S256 is enforced — plain is refused. When the openid scope is requested, a nonce is required and echoed in the ID token.

GET https://login.maillog.dev/authorize
  ?client_id=...        # required
  &redirect_uri=...     # exact match against the client's list
  &response_type=code   # only "code" is supported
  &scope=openid profile email
  &state=...            # required, returned unmodified
  &nonce=...            # required with the openid scope
  &code_challenge=...   # required for public clients (S256)

Redirect URIs are compared exactly — scheme, host (lowercase), port, path — and embedded credentials or whitespace variants are refused. A mismatch shows an error on the provider's own page; nothing is redirected. If the user has no active session yet, they get the login screen in your branding; after login, a consent screen appears unless the client is marked trusted.

POST https://login.maillog.dev/token
  grant_type=authorization_code
  client_id=...                # + client_secret for confidential clients
                               # (also accepted as HTTP Basic auth)
  code=...
  redirect_uri=...
  code_verifier=...            # when PKCE was used

The code is single-use, expires after 60 seconds, and is bound to your team. Replaying a code revokes the whole refresh-token family of that grant.

Tokens and validation

TokenLifetimeNotes
access token15 minJWT, RS256, typ: at+jwt. Validate locally against JWKS.
id token15 minJWT, RS256. Contains nonce when requested.
refresh token30 daysOpaque, rotating. Each use returns a new one; reuse revokes the family.

Validate JWTs against https://login.maillog.dev/jwks.json (cache it — during key rotation the previous key stays published for 24 hours). Clock skew tolerance is 60 seconds. Refreshing:

POST https://login.maillog.dev/token
  grant_type=refresh_token
  client_id=...
  refresh_token=...

The requested scope can go down but never up: a refresh that asks for more than originally granted fails. This prevents silent privilege escalation.

Scopes and claims

ScopeGives
openidID token, sub
profileName
emailEmail and email_verified

Claims: sub, iss, aud, exp, iat, email, email_verified, name. The same claims are served on GET /userinfo with a Bearer access token. Every client has an allowed_scopes list; requesting beyond it fails, and whatever the user consents to is stored and can be revoked later.

Login methods (2FA, passkeys, magic links)

Included on every plan, including Free:

  • Password + email code. A six-digit code sent by mail; attempts are counted server-side with a lockout.
  • Magic links. One-time link, hashed and time-limited; the response is identical whether or not the address is known.
  • Passkeys (WebAuthn). Platform and roaming authenticators.
  • TOTP (authenticator app) and one-time backup codes as a second factor, per account.
  • Google, Microsoft, Apple, Facebook as external identity providers, linked by the provider's subject ID — never by email address.

When an account has a second factor enabled, the OAuth flow pauses after the first factor: the client sees a normal redirect, the user sees the MFA screen in your branding.

Login on your own domain

Set a login_host (e.g. login.yourdomain.com) on a client, then prove the domain with one DNS TXT record — the same mechanism that verifies sending domains:

Name:  _maillog-login.login.yourdomain.com
Type:  TXT
Value: maillog-login-verification=<per-client value>
       # the exact value is returned when you set the login host,
       # and again from GET /admin/clients/:clientId

Until verification succeeds, the host serves nothing: requests that arrive on an unverified or unknown host redirect to login.maillog.dev, and a valid certificate is only requested after verification. Our own domain cannot be claimed. Changing the host clears its verification; a failed re-check does not unset a previously verified host (a DNS hiccup must not break a customer's login).

Branding the screens

Each client carries a theme: product name, logo URL and two colors. The login, consent, MFA and passkey screens all render in it. Values are validated on save and on render — a value that does not exactly match the expected shape is dropped, not repaired, and falls back to the default. That is what makes arbitrary customer input safe to display:

POST /admin/clients/:clientId
{
  "theme": {
    "naam": "Acme Portal",          // up to 80 chars
    "logo_url": "https://cdn.acme.com/logo.png",  // https only
    "kleur": "#2b5f8f",             // button color, #abc or #aabbcc
    "achtergrond": "#faf9f7"        // page background, same format
  }
}

Only hex colors (#abc / #aabbcc) and HTTPS logo URLs are accepted. CSS, fonts and scripts are deliberately not customizable — that is a security boundary, not a limitation we will lift.

Client management API

Under /admin on the identity host, authenticated with the dashboard session of a team member. Every query is scoped to your team — a client ID from another team does not exist from your point of view.

EndpointWhat it does
POST /admin/clientsCreate. Returns the secret once and the TXT record for the login host.
GET /admin/clientsList your clients.
GET /admin/clients/:idDetails, including the login-host TXT record.
PATCH /admin/clients/:idUpdate redirect URIs, scopes, theme, status, login host.
POST /admin/clients/:id/verify-hostCheck the TXT record now and mark verified.
POST /admin/clients/:id/secretReplace the secret (shown once).
DELETE /admin/clients/:idDisable and revoke all its tokens. History is kept.

Clients are typed confidential (server-side, with a secret) or public (browser/mobile, PKCE instead of a secret). A disabled client's refresh tokens are revoked immediately; access tokens expire within their 15-minute lifetime.

Logout and revocation

# End the provider session (GET or POST):
GET https://login.maillog.dev/logout
  ?post_logout_redirect_uri=...   # must be pre-registered on the client
  &state=...

# Revoke a token:
POST https://login.maillog.dev/revoke
  token=...
  client_id=...

Logging out ends the provider session and revokes refresh tokens. Access tokens already issued remain valid for at most 15 more minutes — that is the trade-off of locally validated JWTs, and a deliberate, documented choice. Apps that need faster revocation can shorten token lifetimes.

Security settings

  • Password hashing: scrypt with explicit parameters stored in the hash (scrypt$N$r$p$salt$hash), upgraded transparently on next login.
  • Rate limiting / lockout: attempts are counted in the database, per account and per IP; an unknown address and a wrong password cost the same time and answer identically.
  • Session fixation: the session token is rotated after every successful login.
  • Tenant isolation: enforced by database constraints (composite foreign keys), not just application code — a token for team A's client can never resolve to team B's account.
  • Audit log: append-only, events with hashed IPs and no secrets — codes, tokens and passwords never appear in logs.
  • Key rotation: signing keys rotate in three states (next/active/retired) with a 24-hour overlap so cached JWKS don't break.

Plans and limits

The first 100 accounts are free, permanently. Paid plans start at €19/month with a 14-day free trial (no credit card). Counts are of active accounts — suspended and deleted ones don't count. Branded domains are counted per verified login host. When a limit is reached, account creation fails with HTTP 402 and one of the standard codes (addon_required, addon_quota_reached, addon_limit_reached) — existing users keep logging in either way.