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 │ │
│ ◄───────────────────────────────────────│ │
Step 1: Request a Magic Link
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):
| Field | Required | Description |
|---|---|---|
email | Yes | The user's email address |
clientId | No | The client ID of a registered application (validated against the registration) |
redirectUri | No | Redirect URI (must match the one registered for the client) |
scopes | No | Requested scopes (space-separated) |
state | No | The OAuth state parameter |
responseType | No | The OAuth response_type parameter (e.g. code) |
responseMode | No | The OAuth response_mode parameter |
nonce | No | The OAuth nonce parameter (OIDC) |
codeChallenge | No | PKCE code challenge |
codeChallengeMethod | No | PKCE 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
}
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:
- The token is validated (it exists, hasn't expired, hasn't been used)
- The user is signed in via a cookie
- If OAuth parameters are present: redirect to
/connect/authorize(the normal flow continues) - If no OAuth parameters: redirect to the user portal
Token characteristics
| Property | Value |
|---|---|
| Format | 256-bit random value, URL-safe Base64 encoding |
| Lifetime | 15 minutes |
| Usage | Single-use only (consumed on click) |
| Storage | SHA256 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):
errorCode | Meaning | Description | User action |
|---|---|---|---|
1 | TokenNotFound | The token does not exist | Request a new magic link |
2 | TokenExpired | The token has expired (>15 minutes) | Request a new magic link |
3 | TokenAlreadyUsed | The token has already been used | Request a new magic link |
4 | UserNotFound | User account not found | Contact support |
5 | UserNotActive | User account deactivated | Contact support |
6 | UserLocked | Account locked (too many failed attempts) | Wait or contact support |
7 | InvalidTokenFormat | The token is malformed | Request a new magic link |
8 | InvalidTicket | Invalid ticket with OAuth parameters | Request a new magic link |
Integration with the OAuth flow
To integrate a magic link with your OAuth flow:
- Prepare the same OAuth parameters as for a regular flow (
clientId,redirectUri,scopes,state, and PKCE if applicable) - Pass them as separate fields in the request to
/api/magiclink/send - 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"
}'