Identity

Sign in with Maiaddy

Let people sign in to your web or mobile app with their Maiaddy account, over standard OpenID Connect.

Sign in with Maiaddy lets people use their Maiaddy account in your app. They sign in on Maiaddy's own page, see what your app is asking for, and choose to allow it. Your app gets a stable ID for the person and, if they agree, their name, photo, email address and phone number.

It is standard OpenID Connect, so any certified client library works. This guide builds a small web app with Node's openid-client, then shows the same sign-in in a Flutter app with flutter_appauth.

Discovery

Point your library at Maiaddy's issuer and it finds everything else:

Issuer      https://accounts.maiaddy.com
Discovery   https://accounts.maiaddy.com/.well-known/openid-configuration

People sign in on accounts.maiaddy.com. The token, userinfo, revocation and key endpoints are on accounts-api.maiaddy.com. Take them from discovery rather than writing them into your code.

WhatMaiaddy
FlowAuthorization code, with PKCE (S256)
Response modequery
Grantsauthorization_code, refresh_token
Client authenticationclient_secret_basic or client_secret_post for web apps; none for single-page and native apps
ID token signingRS256
Subject typepairwise
Scopesopenid, profile, email, phone, offline_access

There is no implicit or hybrid flow, no request, request_uri or claims parameter, and no logout endpoint (see Signing out).

Create an app

In the console, open Apps and create one. Pick the type that matches where your code runs:

TypeForSecretPKCE
Web appA server that swaps the code for tokensYesRecommended
Single-page appCode that runs only in the browserNoRequired
Native appiOS, Android and desktop appsNoRequired

A web app's client secret is shown once, when you create the app. Copy it then. If you lose it, rotate it from the app's page. When you rotate, you can keep the old secret working for up to 7 days while you deploy the new one.

Redirect URIs must match exactly: scheme, host, port and path. Wildcards and fragments aren't allowed.

  • Every type can use https.
  • Web and single-page apps can use http://localhost or http://127.0.0.1 only while the app is in testing.
  • Native apps can also use http://localhost or http://127.0.0.1 at any time, or a scheme of their own in reverse-DNS form, like com.example.yourapp:/oauthredirect.

You can have 25 apps. Each app can have up to 10 redirect URIs, coming to 1,000 characters together.

Testing, then live

A new app starts in testing. While it is:

  • Only you and up to 25 test users can sign in. Add them on the app's Test users page, by the email address of their Maiaddy account. Anyone else is told the app is still in testing, and can switch to another account. Maiaddy doesn't send them back to your app, not even with an error.
  • The consent screen tells people that Maiaddy hasn't reviewed the app.
  • If a sign-in request is wrong, Maiaddy shows what's wrong on its own page instead of sending the person back to your app, so you can see what to fix.
  • The phone scope isn't available.

When it works, choose Ask for a review on the app. Once Maiaddy has reviewed it, the app goes live: anyone with a Maiaddy account can sign in, and phone becomes available. Changes to a live app's consent screen wait for review before people see them.

Scopes and claims

ScopeWhat your app gets
openidsub. Required in every request.
profilename, given_name, family_name, picture
emailemail, email_verified
phonephone_number, phone_number_verified. Live apps only.
offline_accessA refresh token

The same claims come in the ID token and from the userinfo endpoint. The ID token describes the person as they were at sign-in; userinfo always answers with their details as they are now.

The Sign in with Maiaddy button

Put this button wherever people choose how to sign in. It comes in three themes, light, dark and blue, as a rectangle or a pill, and says Sign in, Continue or Sign up with Maiaddy. Use Continue on a page that both signs people in and signs them up.

Sign in with Maiaddy, lightSign in with Maiaddy, darkSign in with Maiaddy, blue
Continue with Maiaddy, light pillContinue with Maiaddy, dark pillThe mark alone, lightThe mark alone, dark circle

Download the buttons (ZIP): an SVG, a PNG and a 2x PNG of every theme, shape and label, plus the mark alone. They are 40px high. The text is outlined, so the files look the same whatever fonts your page has.

In HTML

If you'd rather not use images, build it from this markup and CSS. Swap maiaddy-button--light for --dark or --blue, and add maiaddy-button--pill for the pill.

<a class="maiaddy-button maiaddy-button--light" href="/login">
  <svg viewBox="0 0 108.88 117.28" aria-hidden="true">
    <path fill="#6d38dd" d="M106.92,117.28l-35.94-.02c-.52,0-1.02-.21-1.38-.57l-13.77-13.78c-.38-.39-.88-.58-1.38-.58-.37,0-.75.11-1.08.32-.11.07-.21.15-.31.25h0s-13.78,13.78-13.78,13.78c-.37.37-.87.57-1.39.57H1.96c-.3,0-.56-.07-.8-.18-.12-.05-.24-.12-.34-.19-.91-.65-1.17-2.06-.24-2.98l28.73-28.71c7.53,4.46,16.32,7.01,25.71,7.01s17.61-2.39,24.98-6.58l28.3,28.32c1.23,1.23.36,3.34-1.38,3.34Z"/>
    <path fill="#6ca5f6" d="M55.02,0C32.13,0,13.5,18.62,13.5,41.51c0,13.86,6.83,26.15,17.3,33.7,1.67,1.2,3.43,2.28,5.27,3.23,5.69,2.93,12.13,4.59,18.95,4.59s12.7-1.51,18.2-4.21c1.86-.91,3.65-1.96,5.34-3.13,10.85-7.5,17.97-20.03,17.97-34.18C96.53,18.62,77.91,0,55.02,0ZM57.32,62.91c-.76.08-1.52.12-2.3.12-1.11,0-2.21-.08-3.28-.25-2.28-.35-4.45-1.06-6.43-2.07-7-3.55-11.81-10.83-11.81-19.2,0-11.86,9.65-21.51,21.52-21.51s21.51,9.65,21.51,21.51c0,8.67-5.15,16.16-12.56,19.57-2.06.95-4.3,1.58-6.65,1.83Z"/>
  </svg>
  <span>Sign in with Maiaddy</span>
</a>
.maiaddy-button {
  box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 10px;
  height: 40px; padding: 0 16px 0 12px; border: 1px solid; border-radius: 8px;
  font: 500 14px/1 Lexend, system-ui, sans-serif; text-decoration: none; white-space: nowrap; cursor: pointer;
}
.maiaddy-button svg { height: 20px; width: auto; flex-shrink: 0; }
.maiaddy-button--light { background: #fff; color: #1a1a1a; border-color: #d0d5dd; }
.maiaddy-button--light:hover { background: #f8f9fa; }
.maiaddy-button--dark { background: #133455; color: #fff; border-color: #133455; }
.maiaddy-button--dark:hover { background: #1a4270; border-color: #1a4270; }
.maiaddy-button--blue { background: #336699; color: #fff; border-color: #336699; }
.maiaddy-button--blue:hover { background: #2a5580; border-color: #2a5580; }
.maiaddy-button--dark path, .maiaddy-button--blue path { fill: #fff; }
.maiaddy-button--pill { border-radius: 999px; padding: 0 18px 0 14px; }
.maiaddy-button:focus-visible { outline: 2px solid #336699; outline-offset: 2px; }

The button uses Lexend, a free font, when your page loads it, and your system font otherwise. The sample below uses this markup.

Rules

  • Use the button as it is: don't recolour the mark, change the wording or put another mark inside it.
  • Keep it at least 32px high, with clear space around it of at least half its height.
  • Show it as prominently as any other way to sign in on the page.
  • Use the mark alone only where space is tight, and give it the accessible name "Sign in with Maiaddy".
  • In a mobile app, use the SVG or PNG files.

A web app in Node

This builds a working web app in one file, server.js. It signs people in, shows what your app received, refreshes tokens, and signs out. It uses openid-client, which checks the state, nonce and PKCE, and the ID token's signature, issuer, audience and expiry, for you.

You need Node 20.18 or later, and a Web app in the console with the redirect URI http://localhost:4000/callback and the scopes openid, profile, email and offline_access.

mkdir maiaddy-sign-in && cd maiaddy-sign-in
npm init -y
npm pkg set type=module
npm install express express-session openid-client

Put the app's details in .env, and keep this file out of version control:

MAIADDY_CLIENT_ID=mid_app_...
MAIADDY_CLIENT_SECRET=mid_cs_...
MAIADDY_REDIRECT_URI=http://localhost:4000/callback
MAIADDY_SCOPES=openid profile email offline_access
SESSION_SECRET=any-long-random-string

Each block below goes into server.js, in order.

Discovery

The app reads its settings and fetches Maiaddy's discovery document once, at start-up. Everything after this uses the config it returns.

// Sign in with Maiaddy: a small web app (a "Web app" client, with a secret).
// openid-client does the protocol checks: state, nonce, PKCE, and the id_token's
// signature, issuer, audience and expiry against Maiaddy's published keys.

import crypto from "node:crypto";
import express from "express";
import session from "express-session";
import * as client from "openid-client";

const {
  MAIADDY_ISSUER = "https://accounts.maiaddy.com",
  MAIADDY_CLIENT_ID,
  MAIADDY_CLIENT_SECRET,
  MAIADDY_REDIRECT_URI = "http://localhost:4000/callback",
  MAIADDY_SCOPES = "openid profile email",
  PORT = "4000",
  SESSION_SECRET,
} = process.env;

if (!MAIADDY_CLIENT_ID || !MAIADDY_CLIENT_SECRET) {
  console.error("Set MAIADDY_CLIENT_ID and MAIADDY_CLIENT_SECRET in .env.");
  process.exit(1);
}

// Discovery: every endpoint and the signing keys come from the issuer's
// /.well-known/openid-configuration, so nothing else is hard-coded.
const issuer = new URL(MAIADDY_ISSUER);
const local = issuer.protocol === "http:" && ["localhost", "127.0.0.1"].includes(issuer.hostname);
const config = await client.discovery(
  issuer,
  MAIADDY_CLIENT_ID,
  MAIADDY_CLIENT_SECRET,
  undefined,
  // Plain http only for an issuer on your own machine. Maiaddy itself is https.
  local ? { execute: [client.allowInsecureRequests] } : undefined
);

Sessions and the home page

Your app keeps its own session. Maiaddy's sign-in only starts it. The home page shows the person's details once they have signed in.

const app = express();
app.use(
  session({
    secret: SESSION_SECRET || crypto.randomBytes(32).toString("hex"),
    resave: false,
    saveUninitialized: false,
    // lax: the cookie still comes back on Maiaddy's redirect to /callback.
    cookie: { httpOnly: true, sameSite: "lax", secure: new URL(MAIADDY_REDIRECT_URI).protocol === "https:" },
  })
);

app.get("/", (req, res) => {
  const user = req.session.user;
  if (!user) {
    return res.send(
      page(`
        <p>You're signed out.</p>
        <p><a class="maiaddy-button maiaddy-button--light" href="/login">${MAIADDY_MARK}<span>Sign in with Maiaddy</span></a></p>
        ${req.session.notice ? `<p class="notice">${escape(take(req, "notice"))}</p>` : ""}`)
    );
  }
  res.send(
    page(`
      ${user.picture ? `<img class="avatar" src="${escape(user.picture)}" alt="">` : ""}
      <p>Signed in as <strong>${escape(user.name ?? user.sub)}</strong>${user.email ? ` (${escape(user.email)})` : ""}.</p>
      ${req.session.notice ? `<p class="notice">${escape(take(req, "notice"))}</p>` : ""}
      <h2>ID token claims</h2>
      <pre>${escape(JSON.stringify(req.session.idTokenClaims, null, 2))}</pre>
      <h2>Userinfo</h2>
      <pre>${escape(JSON.stringify(user, null, 2))}</pre>
      <form method="post" action="/logout"><button>Sign out</button></form>
      ${req.session.refreshToken ? `<form method="post" action="/refresh"><button>Refresh tokens</button></form>` : ""}`)
  );
});

Send the person to Maiaddy

Every sign-in gets a fresh state, nonce and PKCE verifier. They stay in your session until the person comes back.

app.get("/login", async (req, res) => {
  // A fresh state, nonce and PKCE verifier for every attempt, kept in this
  // app's session until Maiaddy sends the person back.
  const codeVerifier = client.randomPKCECodeVerifier();
  const state = client.randomState();
  const nonce = client.randomNonce();
  req.session.pending = { codeVerifier, state, nonce };

  const url = client.buildAuthorizationUrl(config, {
    redirect_uri: MAIADDY_REDIRECT_URI,
    scope: MAIADDY_SCOPES,
    code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
    code_challenge_method: "S256",
    state,
    nonce,
  });
  req.session.save(() => res.redirect(url.href));
});

The person signs in to Maiaddy if they aren't already, and sees your consent screen the first time. Later sign-ins skip consent unless you ask for more scopes.

Handle the callback

Maiaddy sends the person back with a code, which your server swaps for tokens. authorizationCodeGrant checks the state, the iss parameter Maiaddy adds to every response, the PKCE verifier and the ID token. Then the app reads the person's details from userinfo.

app.get("/callback", async (req, res) => {
  const pending = req.session.pending;
  delete req.session.pending;
  if (!pending) {
    return signedOut(req, res, "That sign-in had expired. Try again.");
  }

  // The URL Maiaddy sent the person back to, on the registered redirect URI.
  const currentUrl = new URL(MAIADDY_REDIRECT_URI);
  currentUrl.search = new URL(req.originalUrl, currentUrl).search;

  try {
    const tokens = await client.authorizationCodeGrant(config, currentUrl, {
      pkceCodeVerifier: pending.codeVerifier,
      expectedState: pending.state,
      expectedNonce: pending.nonce,
      idTokenExpected: true,
    });
    const claims = tokens.claims();
    // userinfo answers for the access token's person; passing sub makes sure
    // it is the same person the id_token named.
    const user = await client.fetchUserInfo(config, tokens.access_token, claims.sub);

    req.session.regenerate((err) => {
      if (err) return res.status(500).send(page("<p>Couldn't start a session.</p>"));
      req.session.user = user;
      req.session.idTokenClaims = claims;
      req.session.accessToken = tokens.access_token;
      req.session.refreshToken = tokens.refresh_token;
      req.session.save(() => res.redirect("/"));
    });
  } catch (error) {
    // The person said no, the app can't be used right now, or a check failed.
    // error.error carries the OAuth error code when Maiaddy sent one.
    console.error("Sign-in failed:", error.error ?? error.message);
    signedOut(req, res, describe(error));
  }
});

Refresh, and sign out

With offline_access, your app gets a refresh token. Signing out of your app revokes it, which also ends the access tokens that came with it.

app.post("/refresh", async (req, res) => {
  if (!req.session.refreshToken) return res.redirect("/");
  try {
    // Refresh tokens rotate: every use answers a new one, and the old one stops working.
    const tokens = await client.refreshTokenGrant(config, req.session.refreshToken);
    req.session.accessToken = tokens.access_token;
    req.session.refreshToken = tokens.refresh_token;
    req.session.user = await client.fetchUserInfo(config, tokens.access_token, req.session.idTokenClaims.sub);
    req.session.notice = "Tokens refreshed.";
    res.redirect("/");
  } catch (error) {
    // invalid_grant: the person removed the app, changed their password, or the token expired.
    console.error("Refresh failed:", error.error ?? error.message);
    signedOut(req, res, "Your Maiaddy sign-in has ended. Sign in again.");
  }
});
app.post("/logout", async (req, res) => {
  // Signing out of this app leaves the person signed in to Maiaddy itself, as
  // with any OpenID provider. Revoking the refresh token ends this app's grant
  // of tokens; the person isn't asked to consent again next time.
  const refreshToken = req.session.refreshToken;
  if (refreshToken) {
    await client.tokenRevocation(config, refreshToken, { token_type_hint: "refresh_token" }).catch((error) => {
      console.error("Revocation failed:", error.message);
    });
  }
  req.session.destroy(() => res.redirect("/"));
});

app.listen(Number(PORT), () => {
  console.log(`Sample running on http://localhost:${PORT} (issuer ${issuer.href})`);
});

Helpers

The last block holds the small helpers the routes use: messages for the signed-out page, the Maiaddy mark for the button, and the page itself.

function signedOut(req, res, notice) {
  req.session.regenerate(() => {
    req.session.notice = notice;
    req.session.save(() => res.redirect("/"));
  });
}

function describe(error) {
  switch (error.error) {
    case "access_denied":
      return "You chose not to sign in with Maiaddy.";
    case "unauthorized_client":
    case "invalid_client":
      return "This app can't use Sign in with Maiaddy right now.";
    default:
      return "Sign-in didn't work. Try again.";
  }
}

function take(req, key) {
  const value = req.session[key];
  delete req.session[key];
  return value;
}

function escape(value) {
  return String(value).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
}

// The Maiaddy ID mark, for the Sign in with Maiaddy button.
const MAIADDY_MARK = `<svg viewBox="0 0 108.88 117.28" aria-hidden="true"><path fill="#6d38dd" d="M106.92,117.28l-35.94-.02c-.52,0-1.02-.21-1.38-.57l-13.77-13.78c-.38-.39-.88-.58-1.38-.58-.37,0-.75.11-1.08.32-.11.07-.21.15-.31.25h0s-13.78,13.78-13.78,13.78c-.37.37-.87.57-1.39.57H1.96c-.3,0-.56-.07-.8-.18-.12-.05-.24-.12-.34-.19-.91-.65-1.17-2.06-.24-2.98l28.73-28.71c7.53,4.46,16.32,7.01,25.71,7.01s17.61-2.39,24.98-6.58l28.3,28.32c1.23,1.23.36,3.34-1.38,3.34Z"/><path fill="#6ca5f6" d="M55.02,0C32.13,0,13.5,18.62,13.5,41.51c0,13.86,6.83,26.15,17.3,33.7,1.67,1.2,3.43,2.28,5.27,3.23,5.69,2.93,12.13,4.59,18.95,4.59s12.7-1.51,18.2-4.21c1.86-.91,3.65-1.96,5.34-3.13,10.85-7.5,17.97-20.03,17.97-34.18C96.53,18.62,77.91,0,55.02,0ZM57.32,62.91c-.76.08-1.52.12-2.3.12-1.11,0-2.21-.08-3.28-.25-2.28-.35-4.45-1.06-6.43-2.07-7-3.55-11.81-10.83-11.81-19.2,0-11.86,9.65-21.51,21.52-21.51s21.51,9.65,21.51,21.51c0,8.67-5.15,16.16-12.56,19.57-2.06.95-4.3,1.58-6.65,1.83Z"/></svg>`;

function page(body) {
  return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in with Maiaddy sample</title>
<style>
  body { font: 15px/1.5 system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; color: #111; }
  .button, button { display: inline-block; padding: 9px 16px; border-radius: 8px; border: 1px solid #336699; background: #336699; color: #fff; text-decoration: none; font: inherit; cursor: pointer; }
  .maiaddy-button { box-sizing: border-box; display: inline-flex; align-items: center; gap: 10px; height: 40px; padding: 0 16px 0 12px; border: 1px solid; border-radius: 8px; font: 500 14px/1 Lexend, system-ui, sans-serif; text-decoration: none; }
  .maiaddy-button--light { background: #fff; color: #1a1a1a; border-color: #d0d5dd; }
  .maiaddy-button--light:hover { background: #f8f9fa; }
  .maiaddy-button:focus-visible { outline: 2px solid #336699; outline-offset: 2px; }
  .maiaddy-button svg { height: 20px; width: auto; }
  form { display: inline-block; margin: 0 8px 8px 0; }
  pre { background: #f4f5f7; padding: 12px; border-radius: 8px; overflow-x: auto; font-size: 13px; }
  .avatar { width: 56px; height: 56px; border-radius: 50%; }
  .notice { color: #8a4b00; }
</style>
</head>
<body>
<h1>Sign in with Maiaddy sample</h1>
${body}
</body>
</html>`;
}

Run it

node --env-file=.env server.js

Open http://localhost:4000 and choose Sign in with Maiaddy. Sign in as yourself or one of your test users.

A Flutter app

A mobile app is a Native app in the console. It has no secret. Instead, the app proves it started the sign-in with PKCE, which flutter_appauth does for you.

Give the app a redirect URI on a scheme of your own, in reverse-DNS form and in lowercase, such as com.example.yourapp:/oauthredirect.

flutter pub add flutter_appauth http

On Android, tell AppAuth your scheme in android/app/build.gradle.kts:

android {
    defaultConfig {
        manifestPlaceholders.putAll(
            mapOf("appAuthRedirectScheme" to "com.example.yourapp")
        )
    }
}

On iOS, add it to ios/Runner/Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleTypeRole</key>
    <string>Editor</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>com.example.yourapp</string>
    </array>
  </dict>
</array>

Then sign in, refresh and read userinfo:

import 'dart:convert';

import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:http/http.dart' as http;

const clientId = 'mid_app_...';
const redirectUrl = 'com.example.yourapp:/oauthredirect';
const discoveryUrl = 'https://accounts.maiaddy.com/.well-known/openid-configuration';
const scopes = ['openid', 'profile', 'email', 'offline_access'];

final appAuth = FlutterAppAuth();

/// Signs the person in. Returns null if they close the sign-in page.
Future<AuthorizationTokenResponse?> signIn() async {
  try {
    return await appAuth.authorizeAndExchangeCode(
      AuthorizationTokenRequest(clientId, redirectUrl, discoveryUrl: discoveryUrl, scopes: scopes),
    );
  } on FlutterAppAuthUserCancelledException {
    return null;
  }
}

/// Swaps the refresh token for new tokens. Store the new refresh token: the old
/// one stops working.
Future<TokenResponse> refresh(String refreshToken) {
  return appAuth.token(
    TokenRequest(clientId, redirectUrl, discoveryUrl: discoveryUrl, refreshToken: refreshToken, scopes: scopes),
  );
}

/// The person's current details. The address is userinfo_endpoint from discovery.
Future<Map<String, dynamic>> userinfo(String accessToken) async {
  final response = await http.get(
    Uri.parse('https://accounts-api.maiaddy.com/oauth2/userinfo'),
    headers: {'Authorization': 'Bearer $accessToken'},
  );
  if (response.statusCode != 200) {
    throw Exception('userinfo failed: ${response.statusCode}');
  }
  return jsonDecode(response.body) as Map<String, dynamic>;
}

Keep the tokens in secure storage, such as flutter_secure_storage, not in shared preferences. If the person chooses Cancel on the consent screen, authorizeAndExchangeCode throws a FlutterAppAuthPlatformException with the error access_denied.

If your app has its own backend, don't treat an ID token that the phone sends it as proof of who signed in until the backend has checked its signature. Or have the backend call userinfo with the access token.

Tokens

TokenLastsNotes
Authorization code60 secondsWorks once. A second use is refused, and can end the tokens the first use gave.
Access token1 hourOpaque, starts mid_at_. Send it to userinfo.
ID token1 hourA JWT signed with RS256. Check it, then use its claims.
Refresh token30 days unusedOnly with offline_access. Starts mid_rt_.

Every token response has an ID token, refreshes included. A refreshed ID token has no nonce.

At the token endpoint, a web app sends its secret with HTTP Basic or in the form body, never both. Single-page and native apps send only their client_id and the PKCE code_verifier. The token, userinfo and revocation endpoints accept calls from any origin, so a single-page app can call them from the browser.

The revocation endpoint always answers 200. Revoking a refresh token also ends the access tokens that came with it.

Signing out

Maiaddy has no logout endpoint for apps, and doesn't tell your app when someone signs out of Maiaddy. To sign someone out of your app, end your own session and revoke the refresh token, as the web app above does.

The person stays signed in to Maiaddy, so their next sign-in may come straight back without a password. To make them sign in again, add prompt=login to the authorization request. To let them pick another Maiaddy account, use prompt=select_account.

What ends your tokens

WhenYour tokensConsent
The person changes their password or signs out everywhereEndKept
The person removes your app from their connected appsEndRemoved: they're asked again
You pause the appRefused while paused, then work againKept
You delete the appEndRemoved
Maiaddy suspends the appEndKept
The person signs out of MaiaddyKeep workingKept

A refresh that fails with invalid_grant means the person has to sign in again.

Errors

At the authorization endpoint, Maiaddy sends the person back to your redirect URI with error, error_description, your state and iss:

errorWhy
access_deniedThe person chose Cancel.
invalid_requestSomething in the request is missing or wrong, such as PKCE for a single-page or native app.
invalid_scopeopenid is missing, or the app isn't set up for a scope it asked for.
login_requiredprompt=none, and the person isn't signed in to Maiaddy.
consent_requiredprompt=none, and the person hasn't allowed everything you asked for.
account_selection_requiredprompt=none, and more than one account is signed in. Send login_hint with the person's email address.

If the client_id is unknown or the redirect URI isn't registered, Maiaddy can't safely send the person back, so it shows an error page instead. It does the same while your app is paused or suspended. While your app is in testing, it also shows request errors such as invalid_request and invalid_scope on the page, and prompt=none errors where Maiaddy can't tell who is signing in.

The token endpoint answers errors as JSON, error and error_description:

errorWhy
invalid_grantThe code or refresh token is expired, used, or revoked, or the person removed your app.
invalid_clientThe client ID or secret is wrong.
unauthorized_clientThe app is paused or suspended.
invalid_requestSomething is missing or wrong, such as the secret sent both ways at once.
unsupported_grant_typeOnly authorization_code and refresh_token are supported.

Userinfo answers 401 with invalid_token for a token it doesn't accept, and 403 with insufficient_scope for one granted without openid.

What's next

  • Manage your apps, test users and consent screen in Apps.
Updated 2026-11-01