Skip to main content

Magic Link authentication

Magic Link provides passwordless authentication via email. Users click a secure, time-limited link and sign in without entering a password.

How it works​

┌──────────┐         ┌──────────┐         ┌──────────┐         ┌──────────┐
│ User │ │ Your App │ │ Klubero │ │ Email │
│ │ │ │ │ SSO │ │ Server │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
│ 1. Enter email │ │ │
│ ──────────────────►│ │ │
│ │ │ │
│ │ 2. Request magic │ │
│ │ link │ │
│ │ ──────────────────►│ │
│ │ │ │
│ │ │ 3. Send email │
│ │ │ ──────────────────►│
│ │ │ │
│ 4. Receive email │ │ │
│ ◄────────────────────────────────────────────────────────────│
│ │ │ │
│ 5. Click the link │ │ │
│ ────────────────────────────────────────► │
│ │ │ │
│ 6. Signed in, redirect to the app │ │
│ ◄───────────────────────────────────────│ │

Endpoint: POST /api/magiclink/send (anonymous)

curl -X POST https://your-sso-domain.com/api/magiclink/send \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"clientId": "my-app",
"redirectUri": "https://myapp.com/callback",
"responseType": "code",
"scopes": "openid profile email",
"state": "xyz",
"codeChallenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"codeChallengeMethod": "S256"
}'

Request body (CreateMagicLinkRequest, camelCase):

FieldRequiredDescription
emailYesThe user's email address
clientIdNoThe client ID of a registered application (validated against the registration)
redirectUriNoRedirect URI (must match the one registered for the client)
scopesNoRequested scopes (space-separated)
stateNoThe OAuth state parameter
responseTypeNoThe OAuth response_type parameter (e.g. code)
responseModeNoThe OAuth response_mode parameter
nonceNoThe OAuth nonce parameter (OIDC)
codeChallengeNoPKCE code challenge
codeChallengeMethodNoPKCE method (S256 or plain)

The OAuth parameters are passed as separate fields (not as a single returnUrl). If clientId and redirectUri are provided, they are validated against the registered client and must match.

Response (MagicLinkResultModel):

{
"success": true,
"errorMessage": null,
"emailSent": true,
"expiresAt": null
}
Security note

For a valid client, the response is always successful (success: true) in order to prevent email enumeration attacks. The user only receives an email if their account exists.

If an invalid clientId or a redirectUri that doesn't match the registration is provided, the endpoint returns HTTP 400 with ValidationProblemDetails or a body of { "success": false, "errorMessage": "..." }.

Step 2: The user clicks the link​

The email contains a link in this format:

https://your-sso-domain.com/Account/MagicLink?token=BASE64URL_TOKEN&...

The link contains the token and a "ticket" carrying the passed OAuth parameters (client, redirect URI, scopes, state, PKCE, etc.), so the authorization flow can continue after sign-in.

Step 3: Complete authentication​

When the user clicks the link:

  1. The token is validated (it exists, hasn't expired, hasn't been used)
  2. The user is signed in via a cookie
  3. If OAuth parameters are present: redirect to /connect/authorize (the normal flow continues)
  4. If no OAuth parameters: redirect to the user portal

Token characteristics​

PropertyValue
Format256-bit random value, URL-safe Base64 encoding
Lifetime15 minutes
UsageSingle-use only (consumed on click)
StorageSHA256 hash stored in the database

Validating a token (without consuming it)​

Endpoint: GET /api/magiclink/validate?token= (anonymous)

Check a token's validity without consuming it:

curl "https://your-sso-domain.com/api/magiclink/validate?token=TOKEN_VALUE"

Response (ValidateMagicLinkResult) — valid token:

{
"valid": true,
"userGuid": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"email": "j***@example.com",
"errorMessage": null,
"errorCode": null
}

Response — invalid token:

{
"valid": false,
"userGuid": null,
"email": null,
"errorMessage": "Token expired.",
"errorCode": 2
}

The errorCode field is a number according to the table in the Error codes section. The response does not include an expiresAt field.

Revoking all pending links​

Endpoint: POST /api/magiclink/revoke-all (requires authentication)

Revoke all pending magic links for the authenticated user:

curl -X POST https://your-sso-domain.com/api/magiclink/revoke-all \
-H "Authorization: Bearer ACCESS_TOKEN"

Response:

{
"revokedCount": 2
}

Error codes​

The errorCode field in the validate response is a numeric value (the enum is serialized as a number):

errorCodeMeaningDescriptionUser action
1TokenNotFoundThe token does not existRequest a new magic link
2TokenExpiredThe token has expired (>15 minutes)Request a new magic link
3TokenAlreadyUsedThe token has already been usedRequest a new magic link
4UserNotFoundUser account not foundContact support
5UserNotActiveUser account deactivatedContact support
6UserLockedAccount locked (too many failed attempts)Wait or contact support
7InvalidTokenFormatThe token is malformedRequest a new magic link
8InvalidTicketInvalid ticket with OAuth parametersRequest a new magic link

Integration with the OAuth flow​

To integrate a magic link with your OAuth flow:

  1. Prepare the same OAuth parameters as for a regular flow (clientId, redirectUri, scopes, state, and PKCE if applicable)
  2. Pass them as separate fields in the request to /api/magiclink/send
  3. After the user clicks the link, they automatically continue the OAuth flow (SSO restores the authorization request from the ticket)

Example:

curl -X POST https://your-sso-domain.com/api/magiclink/send \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"clientId": "my-app",
"redirectUri": "https://myapp.com/callback",
"responseType": "code",
"scopes": "openid profile email",
"state": "xyz",
"codeChallenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"codeChallengeMethod": "S256"
}'