Documentation

Everything you need to run the full passwordless-auth loop with nothing but a tenant id and a public key. The endpoints below are open — no SDK, no login, no dashboard required.

The axiom. Magic Links makes one and only one claim per JWT: “We sent code C to email E. The holder of code C returned it. Therefore E is verified.” We don’t store users, sessions, roles, or anything user-shaped. We sign verified emails into JWTs your app verifies with the tenant’s public key. That’s the whole product.
Contents

Quickstart — the full loop

If you already have a tenant_id and the tenant’s public_key_pem (from the dashboard or from GET /api/tenants/{id}), you can run the entire authentication loop without ever touching a UI.

1. Send a code

curl -X POST https://magiclink.effortlessapi.com/api/tenants/$TENANT_ID/send-code \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com"}'

# → { "ok": true }    (always — does not reveal whether email exists)

2. Verify the code, get a JWT

curl -X POST https://magiclink.effortlessapi.com/api/tenants/$TENANT_ID/verify-code \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","code":"123456"}'

# → { "ok": true, "jwt": "eyJ...", "expires_in": 300 }

3. Verify the JWT in your app — using only the public key

const jwt = require('jsonwebtoken');

const decoded = jwt.verify(token, PUBLIC_KEY_PEM, {
  algorithms: ['RS256'],
  issuer: `https://magiclink.effortlessapi.com/${TENANT_ID}`,
});

// decoded.email is now a verified email — that's the entire claim.

That is the whole loop. No SDK, no callback URL, no session storage on our side. Anything you do after that — sessions, roles, tenant ownership — is yours to design.

Concepts

Tenant

A tenant is a UUID + an RSA keypair. The private key signs JWTs; the public key verifies them. Each tenant has its own from_email and jwt_expires_in_seconds. Tenants are independent — JWTs from tenant A are not valid in tenant B.

Public key

PKCS#8 PEM (-----BEGIN PUBLIC KEY-----). Safe to embed in clients, commit to source, paste into Postgres. It is the only thing your verifier needs.

JWT

RS256, standard claims plus email and tenant_id:

{
  "sub":       "user@example.com",
  "email":     "user@example.com",
  "tenant_id": "00000000-0000-0000-0000-000000000000",
  "iss":       "https://magiclink.effortlessapi.com/00000000-...",
  "iat":       1714200000,
  "nbf":       1714200000,
  "exp":       1714200300
}

The iss claim is ${AUTH_BASE_URL}/${tenant_id} — verify it strictly.

Public endpoints

Base URL: https://magiclink.effortlessapi.com. These four endpoints are all you need if you already have a tenant.

POST/api/tenants/{id}/send-codepublic
POST/api/tenants/{id}/verify-codepublic
POST/api/tenants/{id}/refreshBearer (recently-expired JWT)
GET/api/tenants/{id}public — returns public key

POST /api/tenants/{id}/send-code

Issue a 6-digit code and email it. Always returns {ok: true}, even for unknown tenants and missing emails — this prevents enumeration.

Body:
{
  "email": "user@example.com",
  "additional_claims": { "role": "admin" }   // optional, see below
}

POST /api/tenants/{id}/verify-code

Exchange a code for a JWT. Codes are single-use, 5-minute TTL, in-memory.

Body:
{
  "email": "user@example.com",
  "code":  "123456",
  "additional_claims": { "plan": "pro" }     // optional — merges with send-time claims
}

200 → { "ok": true, "jwt": "...", "expires_in": 300 }
401 → { "ok": false, "error": "invalid_or_expired_token" }

GET /api/tenants/{id}

Returns the public information for a tenant — including the public_key_pem you need to verify JWTs.

200 → {
  "tenant_id":              "00000000-...",
  "public_key_pem":         "-----BEGIN PUBLIC KEY-----\n...",
  "from_email":             "noreply@magiclink.effortlessapi.com",
  "jwt_expires_in_seconds": 300,
  "created_at":             "2026-04-27T00:00:00Z"
}

Pass-through claims

Both send-code and verify-code accept an additional_claims object. Whatever you pass gets baked verbatim into the issued JWT alongside the verified email.

Magic Links forms no opinion on these claims — it just signs them. This lets you carry role, app_user_id, plan, anything you need, without wrapping or re-signing the token.

Reserved claims are not overrideable: email, iss, iat, nbf, exp, sub, tenant_id.

POST /api/tenants/{id}/send-code
{ "email": "u@x.com", "additional_claims": { "role": "admin", "org_id": 42 } }

→ JWT payload includes:  role: "admin", org_id: 42

Refresh tokens

Within a 60-second grace window after a JWT expires, you can refresh it without going through the email loop again. Send the recently-expired JWT as a Bearer token.

curl -X POST https://magiclink.effortlessapi.com/api/tenants/$TENANT_ID/refresh \
  -H "Authorization: Bearer $EXPIRED_JWT" \
  -H "Content-Type: application/json" \
  -d '{"grace_period": 60}'

200 → { "ok": true, "jwt": "...", "expires_in": 300 }
401 → { "ok": false, "error": "expired_beyond_grace" }

All non-reserved claims (including any additional_claims) are carried forward into the new JWT.

Verifying the JWT

You only need the tenant’s public_key_pem. The JWT is RS256 — every standard library can verify it.

Node.js

const jwt = require('jsonwebtoken');

const TENANT_ID = process.env.TENANT_ID;
const PUBLIC_KEY = process.env.PUBLIC_KEY_PEM;

function verify(token) {
  return jwt.verify(token, PUBLIC_KEY, {
    algorithms: ['RS256'],
    issuer: `https://magiclink.effortlessapi.com/${TENANT_ID}`,
  });
}

Python

import jwt  # pyjwt

decoded = jwt.decode(
    token,
    PUBLIC_KEY,
    algorithms=["RS256"],
    issuer=f"https://magiclink.effortlessapi.com/{TENANT_ID}",
)

Postgres (pgjwt)

SELECT (extensions.verify(jwt, public_key, 'RS256')).valid;

Postgres RLS pattern

Drop the public key in your database, expose auth.email(), and use it directly in policies. The DB enforces access — your app can have bugs without leaking rows.

CREATE TABLE auth.trusted_tenants (
  tenant_id      uuid PRIMARY KEY,
  public_key_pem text NOT NULL
);

CREATE FUNCTION auth.email() RETURNS text LANGUAGE sql STABLE AS $$
  SELECT current_setting('request.jwt.claim.email', true)
$$;

CREATE POLICY user_orders ON orders
  USING (customer_email = auth.email());

Your gateway verifies the JWT against the matching public_key_pem and sets the claim on the connection (Postgrest, Hasura, or your own). Migrate to a different issuer later? Replace the row in auth.trusted_tenants. Policies don’t change.

Errors & debug mode

ErrorMeaning
invalid_or_expired_tokenCode wrong, expired (5 min), already used, or tenant unknown
expired_beyond_graceJWT is older than the refresh grace window
tenant_mismatchRefresh attempted on a JWT issued by a different tenant
tenant_not_foundTenant id is unknown (only on management endpoints)
invalid_tenant_id_formatTenant id is not a valid UUID

Debug mode

When the server runs with NODE_ENV not equal to production, the literal code 424242 (or whatever MAGIC_AUTH_DEBUG_CODE is set to) is accepted for any email on any tenant. Never enable in production.

Tenant management self-auth

Creating, updating, or deleting tenants requires a self-auth JWT — see Self-auth. Once you have a tenant id and public key, none of these endpoints are needed for day-to-day login traffic.

POST/api/tenantsself-auth JWT
PATCH/api/tenants/{id}self-auth JWT + ownership
DELETE/api/tenants/{id}self-auth JWT + ownership

POST /api/tenants

Server generates the keypair. The private key never leaves the server.

Body:
{
  "from_email":             "noreply@yourapp.com",   // optional
  "jwt_expires_in_seconds": 300                       // optional, default 300
}

Headers:
  Authorization: Bearer <self-auth JWT>

200 → {
  "tenant_id":              "...",
  "public_key_pem":         "-----BEGIN PUBLIC KEY-----\n...",
  "from_email":             "...",
  "jwt_expires_in_seconds": 300
}

Self-auth — managing your own tenants

The magic-links service is itself a magic-links tenant. To prove you own a tenant in the database, you authenticate with the same email-OTP loop against the self-tenant.

POST/auth/send-codepublic
POST/auth/verify-codepublic
GET/meself-tenant JWT
# 1. Get a self-tenant JWT
curl -X POST https://magiclink.effortlessapi.com/auth/send-code \
  -d '{"email":"you@yourdomain.com"}' -H 'Content-Type: application/json'

curl -X POST https://magiclink.effortlessapi.com/auth/verify-code \
  -d '{"email":"you@yourdomain.com","code":"123456"}' -H 'Content-Type: application/json'
# → { "ok": true, "jwt": "<self-auth JWT>", "expires_in": 300 }

# 2. Use it to create a tenant
curl -X POST https://magiclink.effortlessapi.com/api/tenants \
  -H "Authorization: Bearer <self-auth JWT>" \
  -H "Content-Type: application/json" \
  -d '{"from_email":"noreply@yourapp.com"}'

The self-tenant JWT carries an app_user_id claim — the server uses that to enforce tenant ownership on PATCH/DELETE. You can decode the JWT client-side to read it; it’s also returned by GET /me.

Environment variables (self-host)

Running your own copy of magic-links? These are the env vars that matter.

VarPurposeDefault
PORTHTTP port3131
NODE_ENVproduction disables the debug codeunset
AUTH_BASE_URLUsed as the JWT iss prefixhttp://localhost:3131
POSTGRES_URLTenant store
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROMNodemailer transportGmail SMTP
MAGIC_AUTH_DEBUG_CODEUniversal bypass code in non-prod424242

More