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: /tokenExample 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 usedThe 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
| Token | Lifetime | Notes |
|---|---|---|
| access token | 15 min | JWT, RS256, typ: at+jwt. Validate locally against JWKS. |
| id token | 15 min | JWT, RS256. Contains nonce when requested. |
| refresh token | 30 days | Opaque, 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
| Scope | Gives |
|---|---|
| openid | ID token, sub |
| profile | Name |
Email 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/:clientIdUntil 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.
| Endpoint | What it does |
|---|---|
| POST /admin/clients | Create. Returns the secret once and the TXT record for the login host. |
| GET /admin/clients | List your clients. |
| GET /admin/clients/:id | Details, including the login-host TXT record. |
| PATCH /admin/clients/:id | Update redirect URIs, scopes, theme, status, login host. |
| POST /admin/clients/:id/verify-host | Check the TXT record now and mark verified. |
| POST /admin/clients/:id/secret | Replace the secret (shown once). |
| DELETE /admin/clients/:id | Disable 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.