# Magic Link Sign-in

> Passwordless email sign-in for the inference shell workspace. The same email includes a clickable link and a short login code for users who read email on a different device than the one they sign in o...

**URL:** https://inference.sh/docs/api/rest/magic-link
**Last updated:** 2026-10-31

---

Passwordless email sign-in for the inference shell workspace. The same email includes a clickable link and a short **login code** for users who read email on a different device than the one they sign in on.

The workspace app uses this flow on the login page. You can call the endpoints directly to build a custom sign-in experience.

No API key is required for send or verify endpoints. Successful sign-in sets a **session cookie** on the response (same cookie the workspace uses for browser sessions). For accounts that require a second factor, the session cookie is issued only after the second factor is verified. See [Login second factor](#login-second-factor).

→ [Authentication](/docs/api/authentication) · [Device authorization](/docs/api/rest/device-auth) (CLI and IDE login)

---

## Flow overview

```mermaid
sequenceDiagram
    participant Client as Your app or browser
    participant API as api.inference.sh
    participant Email as User email

    Client->>API: POST /auth/magic-link/send {email}
    API->>Email: Magic link + 5-char login code
    alt Click link (same device)
        Email->>API: GET /auth/magic-link/verify?token=…
        API-->>Client: Redirect (session cookie set, or 2FA prompt)
    else Enter code (different device)
        Client->>API: POST /auth/magic-link/verify-code {email, code}
        API-->>Client: AuthResponse (session cookie set, or challenge_token)
    end
    opt Login second factor required
        Client->>API: POST /auth/otp/verify {method, code, challenge_token}
        API-->>Client: AuthResponse + session cookie
    end
```

1. Call `POST /auth/magic-link/send` with the user's email.
2. The user receives an email with a verification link and a **5-character login code** (valid for **15 minutes**). Both redeem the same challenge — using one consumes the other.
3. Complete sign-in by clicking the link (`GET /auth/magic-link/verify`) or submitting the code (`POST /auth/magic-link/verify-code`).
4. **Platform staff** always require a second factor before a session is created. **Members** require one when [two-step verification](/docs/api/rest/second-factor) is turned on. After magic-link or code sign-in, browser clients are redirected to `/auth/verify?challenge_token=…` (and optional `redirect_to`). JSON clients receive `otp_required: true`, `otp_methods`, and `challenge_token`. Complete sign-in with the [verify endpoints](#login-second-factor). No session cookie is issued until verification succeeds.

---

## Send magic link

`POST /auth/magic-link/send`

**Authentication:** None.

### Request body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `email` | string | Yes | Email address to sign in with. Surrounding whitespace is ignored; matching is case-insensitive. New accounts store the trimmed, lowercased form (see [REST overview: Signup email restrictions](/docs/api/rest/overview#signup-email-restrictions)). |
| `redirect_to` | string | No | Path or URL appended to the verification link (for example `/dashboard`) |

```bash
curl -X POST https://api.inference.sh/auth/magic-link/send \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com"}'
```

### Response

The API always returns success to prevent email enumeration, even when the address is unknown or rate limited (except IP-level throttling):

```json
{
  "message": "If an account exists, a magic link has been sent"
}
```

When the same email requests another link within the burst window (about one per minute), the response includes:

```json
{
  "message": "Please wait before requesting another link",
  "rate_limited": true,
  "retry_after": 60
}
```

IP-level rate limits return HTTP **429** with a `Retry-After` header (seconds). The header is exposed to browser clients via CORS.

### Errors

| Code | HTTP | Description |
|------|------|-------------|
| `missing_email` | 400 | `email` is empty |
| `disposable_email` | 400 | Temporary or disposable email domain. Applies to new signups only; existing accounts are not affected. The same restriction applies when an account is created through OAuth. See [REST overview: Signup email restrictions](/docs/api/rest/overview#signup-email-restrictions). |
| `invalid_email` | 400 | Malformed address. When plus-alias signup blocking is enabled, also returned for a **new** sign-up whose local part contains `+` on providers that do not fold aliases into one mailbox (Gmail/Googlemail and existing accounts are exempt). See [REST overview: Signup email restrictions](/docs/api/rest/overview#signup-email-restrictions). |

---

## Verify via link (browser)

`GET /auth/magic-link/verify?token={token}`

**Authentication:** None.

Used when the user clicks the link in the email. On success, the API sets the session cookie and redirects to the workspace (or to `redirect_to` when provided at send time).

Accounts that require a login second factor are redirected without a session cookie to `/auth/verify` with a `challenge_token` query parameter (and `redirect_to` when the sign-in carried a safe in-app destination).

For SPA or mobile clients that handle the token themselves, use the JSON endpoint below instead.

---

## Verify via token (JSON)

`POST /auth/magic-link/verify`

**Authentication:** None.

### Request body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `token` | string | Yes | Token from the magic link URL |

```bash
curl -X POST https://api.inference.sh/auth/magic-link/verify \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{"token": "TOKEN_FROM_EMAIL_LINK"}'
```

### Response

When sign-in completes without 2FA:

```json
{
  "user": { "id": "user_abc", "email": "you@example.com", "username": "you" },
  "session_id": "sess_xyz",
  "otp_required": false
}
```

When a login second factor is required, the response omits `session_id` and no session cookie is set:

```json
{
  "user": { "id": "user_abc", "email": "you@example.com", "username": "you" },
  "otp_required": true,
  "otp_methods": ["email"],
  "challenge_token": "CHALLENGE_TOKEN"
}
```

`otp_methods` lists the factors the account may use (`email`, `totp`, `passkey`, `recovery`), default order first. Complete [login second factor](#login-second-factor) with the `challenge_token` before calling authenticated routes. The session cookie is set on the successful `POST /auth/otp/verify` response.

---

## Verify via login code

`POST /auth/magic-link/verify-code`

**Authentication:** None.

Device-independent sign-in: the user reads the 5-character code from email and enters it on the sign-in device.

### Request body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `email` | string | Yes | Same email used in `send` (whitespace and letter case are ignored when matching) |
| `code` | string | Yes | 5-character code from the email (case-insensitive) |

```bash
curl -X POST https://api.inference.sh/auth/magic-link/verify-code \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{"email": "you@example.com", "code": "AB3K9"}'
```

### Response

Same `AuthResponse` shape as [verify via token](#verify-via-token-json). The session cookie is set when sign-in completes: immediately when no 2FA is required, after OTP verification otherwise.

### Errors

| Code | HTTP | Description |
|------|------|-------------|
| `invalid_code` | 400 | Code wrong, expired, or already used |
| `account_locked` | 429 | Too many failed attempts for this email |
| `rate_limited` | 429 | Too many verification attempts from this IP |

Failed code attempts count toward per-email lockout. Codes use an alphanumeric charset without ambiguous characters (`0`, `1`, `O`, `I`, `L`).

---

## Login second factor

Passwordless sign-in (magic link, login code, OAuth, or SAML) may stop before a session exists when the account [requires a second factor](/docs/api/rest/second-factor#when-a-second-factor-is-required). This is separate from the 5-character **login code** in the email.

| Mode | When | Credential |
|------|------|------------|
| **Pre-session** | Immediately after sign-in, before any session exists | `challenge_token` from the auth response or `/auth/verify` URL |
| **Re-auth** | A sensitive route returns `403` with code `otp_required` on an existing session | Session cookie |

The verify routes accept `method` (`email`, `totp`, `passkey`, `recovery`), an emailed or authenticator **code**, or a **passkey assertion** from `POST /auth/passkey/assert/begin`. Login challenges expire after **10 minutes**. After a successful verify, the session is marked re-authenticated for **4 hours**.

Full request shapes, passkey flows, lockout rules, and re-auth behavior are documented in [Two-step verification API: login second factor](/docs/api/rest/second-factor#login-second-factor).

### Pre-session (magic link JSON)

```bash
curl "https://api.inference.sh/auth/otp/status?challenge_token=CHALLENGE_TOKEN"

curl -X POST https://api.inference.sh/auth/otp/verify \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{"challenge_token": "CHALLENGE_TOKEN", "method": "email", "code": "123456"}'
```

On success, `POST /auth/otp/verify` returns an `AuthResponse` with `session_id` and sets the session cookie. The same pre-session flow applies to OAuth and SAML when `otp_required` is true.

### Re-auth (existing session)

When an OTP-gated route returns `403` with code `otp_required`, call `GET /auth/otp/status` and `POST /auth/otp/verify` with the session cookie (no `challenge_token`). Resend applies only when `email` is among the offered methods.

---

## Related

- [Device authorization](/docs/api/rest/device-auth) — CLI and IDE login (returns an API key instead of a session cookie)
- [Authentication](/docs/api/authentication) — API keys for programmatic access
- [REST API overview](/docs/api/rest/overview#path-prefix) — `/v1/` path prefix removed
