Skip to main content

Configure SMTP So Keycloak Can Send Email

Keycloak sends no email at all until a realm has an SMTP server configured, and the setting lives per realm, not per server. To configure Keycloak SMTP, fill in four things under Realm settings → Email, in this order:

  1. From — a sender address. The realm will not save without one.
  2. Host and Port — your relay and its port.
  3. Encryption — StartTLS for port 587, Enable SSL for port 465. Exactly one.
  4. Authentication — on, with a username and either a password or a token.

Then press Test connection, which sends a message to your own admin account's email address. If your admin user has no email address on it, the test fails with Failed to send email and no cause — that is the single most common first stumble, and it is not an SMTP problem at all.

Tested against

Keycloak 26.8.1 in a container (quay.io/keycloak/keycloak:26.8.1 start-dev), against a real SMTP server with STARTTLS, implicit TLS and SMTP AUTH. Every command, error string, log line and header quoted below is copied from a run on 2026-11-02. Provider settings in the matrix come from each provider's own documentation, linked there.

What you'll build​

A realm that sends password-reset and verification mail through an authenticated, TLS-encrypted relay — plus the three checks that prove it is working, and the one that catches it when it silently stops.

That last part matters more than it sounds. When SMTP is broken, the Keycloak forgot-password page still tells the user "You should receive an email shortly with further instructions." It does that on purpose — the message is identical for a user who does not exist, so the form cannot be used to enumerate accounts. The cost is that a dead relay looks exactly like a working one from the outside. Verified below.

Prerequisites​

A Keycloak and somewhere for mail to land. Mailpit is an SMTP server that accepts everything and shows it in a browser, which is what you want while you are still getting the settings right:

docker network create kcnet

docker run -d --name mailpit --network kcnet --memory 256m \
-p 127.0.0.1:8025:8025 axllent/mailpit:latest --smtp-auth-accept-any

docker run -d --name kc --network kcnet --memory 1536m -p 127.0.0.1:8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.8.1 start-dev

Mailpit's inbox is at http://localhost:8025. If that container is your first Keycloak, start with Run Keycloak locally in 5 minutes.

Define the CLI helper once — every command below uses it:

kcadm() { docker exec -i kc /opt/keycloak/bin/kcadm.sh "$@"; }

kcadm config credentials --server http://localhost:8080 \
--realm master --user admin --password admin
Logging into http://localhost:8080 as user admin of realm master

Give the admin account an email address, because the test button sends there:

AID=$(kcadm get users -r master -q username=admin --fields id | jq -r '.[0].id')
kcadm update "users/$AID" -r master -s email=admin@example.com -s emailVerified=true

And a realm with a user in it:

kcadm create realms -s realm=prod -s enabled=true
kcadm create users -r prod -s username=alice -s enabled=true \
-s email=alice@example.com -s firstName=Alice -s lastName=Example
kcadm update realms/prod -s resetPasswordAllowed=true

Step 1 — set the sender and the server​

Admin console

Realm settings → Email tab. Fill in From under Template, then Host and Port under Connection & Authentication, then Save.

kcadm.sh
kcadm update realms/prod \
-s 'smtpServer.host=mailpit' \
-s 'smtpServer.port=1025' \
-s 'smtpServer.from=keycloak@example.com' \
-s 'smtpServer.fromDisplayName=Example Login' \
-s 'smtpServer.replyTo=support@example.com' \
-s 'smtpServer.envelopeFrom=bounces@example.com'

What each field does to the message​

The console's labels do not say which header each one sets, so here it is, read off the raw message Keycloak actually delivered:

Console labelAPI keySetsRequired
FromfromFrom: and, with no envelopeFrom, Return-Path:Yes
From display namefromDisplayNameThe name part of From:No
Reply toreplyToReply-To: — defaults to the From addressNo
Reply to display namereplyToDisplayNameThe name part of Reply-To:No
Envelope fromenvelopeFromReturn-Path:, i.e. where bounces goNo
Hosthost—Yes
Portport—Yes

With all of those set, the delivered message carried:

Return-Path: <bounces@example.com>
From: Example Login <keycloak@example.com>
Reply-To: support@example.com
To: alice@example.com
Subject: Update Your Account

Two things that cost time here:

Put the display name in fromDisplayName, not in from. Keycloak parses from as a bare address and rejects anything else — including the Name <addr@example.com> form that works nearly everywhere else:

Invalid sender address 'Example Login <keycloak@example.com>'.

A realm will not save an SMTP config with no sender. Keycloak validates on write, so a half-finished config fails loudly rather than sitting there looking configured:

kcadm update realms/nofrom -s 'smtpServer.host=mailpit' -s 'smtpServer.port=1025'
echo $?
Invalid sender address 'null'. If the address contains UTF-8 characters in the local part
please ensure the SMTP server supports the SMTPUTF8 extension and enable 'Allow UTF-8' in
the email realm configuration.
1
kcadm get --fields smtpServer lies to you

Reading the config back with a field filter returns an empty object even when the realm is fully configured — the filter does not descend into a map:

kcadm get realms/prod --fields smtpServer
{
"smtpServer" : { }
}

Nothing is wrong. Ask for the sub-fields explicitly, or drop --fields entirely:

kcadm get realms/prod --fields 'smtpServer(host,port,from)'
{
"smtpServer" : {
"host" : "mailpit",
"port" : "1025",
"from" : "keycloak@example.com"
}
}

The same applies to attributes. If you have ever concluded that a kcadm update did not take effect because the read-back came up empty, this is probably why.

Step 2 — choose the encryption, then the port​

There are two unrelated ways to encrypt SMTP and Keycloak exposes both as checkboxes. Pick by what the port expects, not by which sounds safer:

EncryptionAPI keysPort, usuallyWhat happens on the wire
StartTLSstarttls=true, ssl=false587 (also 25, 2525)Connect in the clear, EHLO, upgrade with STARTTLS
Enable SSLssl=true, starttls=false465TLS handshake first, SMTP inside it
Noneboth false—Credentials in plaintext. Local sinks only

Set both and ssl wins: Keycloak opens an implicit TLS connection and never issues STARTTLS.

The error tells you which one you got wrong​

Each mismatch has its own distinctive failure, and knowing the mapping saves a lot of guessing. All four reproduced against a server where the correct answer was known:

You setServer actually wantedError in the server log
ssl=trueSTARTTLSSSLException: Unsupported or unrecognized SSL message
starttls=true or neitherimplicit TLSSocketTimeoutException: Read timed out
either, cert not trustedTLSSSLHandshakeException … PKIX path building failed
auth=true, bad credentials—AuthenticationFailedException: 535 5.7.8 Authentication credentials invalid

The read timeout is the slow one: with a default timeout of 10,000 ms, sending to a TLS-only port with StartTLS ticked hangs for ten seconds per attempt before failing. If "email is slow" is the symptom rather than "email is broken", check this first.

Private CAs and self-signed certificates​

PKIX path building failed means the relay's certificate does not chain to anything in the JVM truststore. That is normal for an internal relay. The fix is a server-level option, not a realm setting — mount the CA or the certificate and point KC_TRUSTSTORE_PATHS at the directory:

docker run -d --name kc --network kcnet --memory 1536m -p 127.0.0.1:8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-e KC_TRUSTSTORE_PATHS=/opt/keycloak/conf/truststores \
-v "$PWD/truststore:/opt/keycloak/conf/truststores:ro" \
quay.io/keycloak/keycloak:26.8.1 start-dev

Keycloak logs what it picked up at startup, which is worth checking before you blame the certificate again:

INFO [org.keycloak.truststore.TruststoreBuilder] Found the following truststore files in
the truststore paths [/opt/keycloak/conf/truststores/relay.crt, …]

With the certificate in place, both StartTLS and implicit TLS to that host succeeded unchanged — no realm setting had to move.

Step 3 — authenticate​

Turn Authentication on and supply a username. Then pick the type.

Password is what almost every relay still wants:

kcadm update realms/prod \
-s 'smtpServer.auth=true' \
-s 'smtpServer.user=apikey' \
-s 'smtpServer.password=<the secret>'

Token is Keycloak's XOAUTH2 support, added in 26.2. Keycloak fetches an access token with the client-credentials grant and presents it to the relay instead of a password — which is what Microsoft 365 is pushing everyone toward as basic authentication for client submission is retired. The keys:

Console labelAPI key
Authentication TypeauthType — basic or token
Auth Token URLauthTokenUrl
Auth Token ScopeauthTokenScope
Auth Token ClientIdauthTokenClientId
Auth Token Client SecretauthTokenClientSecret

Pointed at a working token endpoint, Keycloak fetched a token and offered XOAUTH2 at the AUTH step — the relay in this test did not support that mechanism, so it answered No authentication mechanisms supported by both server and client. That message means the token half worked and the relay is the problem; Failed to gather valid token for SMTP means the opposite.

Before you plan around it, read the limits in the official documentation: the token type is contributed and documented as not tested by the core team, Google is not supported because it does not accept a client secret for the client-credentials grant, and Amazon SES does not support XOAUTH2 at all. For those two, a password is the only option.

Step 4 — verify it, three ways​

Test connection​

The button, and its API equivalent, send [KEYCLOAK] - SMTP test message to the email address of the admin who triggered it:

TOK=$(curl -s -d client_id=admin-cli -d username=admin -d password=admin \
-d grant_type=password \
http://localhost:8080/realms/master/protocol/openid-connect/token | jq -r .access_token)

curl -s -o /dev/null -w "%{http_code}\n" -X POST \
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
http://localhost:8080/admin/realms/prod/testSMTPConnection \
--data '{"host":"mailpit","port":"1025","from":"keycloak@example.com","auth":"false","ssl":"false","starttls":"false"}'
204

204 is success. Two things about this endpoint that are not obvious:

  • It tests the body you send, not what is stored on the realm. In the console that means it tests what is currently typed into the form, including changes you have not saved. A green test does not prove the saved realm works.
  • It needs the calling admin to have an email address. Without one you get {"errorMessage":"Failed to send email"} and a stack trace whose cause is null.

Send a real one​

Better evidence: make Keycloak send the mail a user would actually get.

USER_ID=$(kcadm get users -r prod -q username=alice --fields id | jq -r '.[0].id')

curl -s -o /dev/null -w "%{http_code}\n" -X PUT \
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
"http://localhost:8080/admin/realms/prod/users/$USER_ID/execute-actions-email" \
--data '["VERIFY_EMAIL"]'

curl -s "http://127.0.0.1:8025/api/v1/messages?limit=1" | jq -r '.messages[0].Subject'
204
Update Your Account

A user with no email address on the account gets 400 {"errorMessage":"User email missing"} rather than anything SMTP-related.

Watch the events, because the UI will not tell you​

This is the check worth keeping. Turn on the two email event types:

kcadm update events/config -r prod -s eventsEnabled=true \
-s 'enabledEventTypes=["SEND_RESET_PASSWORD","SEND_RESET_PASSWORD_ERROR","SEND_VERIFY_EMAIL","SEND_VERIFY_EMAIL_ERROR"]' \
-s eventsExpiration=3600

Now run the forgot-password form twice — once with the relay reachable and once with the host pointed at a black hole — and compare. The page says the same thing both times:

You should receive an email shortly with further instructions.

The event log does not:

kcadm get events -r prod | jq -r '.[] | "\(.type) \(.error // "-") \(.details.username)"'
SEND_RESET_PASSWORD - alice
SEND_RESET_PASSWORD_ERROR email_send_failed alice

And the server log carries the reason, under two message codes worth alerting on:

ERROR [org.keycloak.services] KC-SERVICES0029: Failed to send email: …
ERROR [org.keycloak.services] KC-SERVICES0026: Failed to send password reset email: …

A typed-in username that does not exist produces the same page message and no event at all — that is the anti-enumeration behaviour working as designed, and it is why SEND_RESET_PASSWORD_ERROR rather than user complaints is the right thing to watch.

While you are in this area: the same unauthenticated forms are a mail-bombing surface, which is a separate problem with separate mitigations. See email bombing and registration abuse.

Provider settings​

Keycloak talks plain SMTP, so any relay works. What differs is the port, the encryption and what counts as a username. All from each provider's own documentation:

ProviderHostPortEncryptionUsernamePassword
Amazon SESemail-smtp.<region>.amazonaws.com587 / 2587StartTLSSES SMTP usernameSES SMTP password
Amazon SESsame465 / 2465Enable SSLsamesame
SendGridsmtp.sendgrid.net587StartTLSthe literal string apikeyan API key with Mail Send
SendGridsmtp.sendgrid.net465Enable SSLapikeysame
Postmarksmtp.postmarkapp.com587 / 2525StartTLSServer API tokenthe same token
Microsoft 365smtp.office365.com587StartTLSmailbox addresspassword, or authType=token
Google Workspacesmtp.gmail.com587StartTLSfull Workspace addressapp password
Google Workspacesmtp.gmail.com465Enable SSLsamesame

Four things in that table that bite:

  • SES SMTP credentials are not your IAM access key and secret. The username happens to look like an access key ID, but the password is an HMAC derivation of the secret key and is per region. Generate them in the SES console, or derive them with the script AWS publishes. Pasting the IAM secret in produces 535, which reads as "wrong password" and sends you looking in the wrong place. A new SES account is also in the sandbox at 200 messages per 24 hours and can only send to verified addresses.
  • SendGrid's username is the five-character string apikey. Not your account name, not the key.
  • Microsoft explicitly says port 465 does not support the TLS versions it requires for client SMTP submission — use 587. SMTP AUTH is also off by default for tenants created after January 2020 and has to be enabled per mailbox, and basic authentication for client submission is on a published deprecation path.
  • Google Workspace needs an app password, which requires 2-Step Verification on the account. A normal account password fails.

Whichever you pick, send from a domain you control and publish SPF, DKIM and DMARC for it. Authentication-critical mail that lands in spam is the same outage as mail that never sent, and it is the one your monitoring will not catch.

Troubleshooting​

The config disappeared after a restore, or works in staging and not in production​

This is the one that looks like sabotage. A realm export writes the SMTP password as a literal mask:

curl -s -X POST -H "Authorization: Bearer $TOK" \
http://localhost:8080/admin/realms/prod/partial-export | jq .smtpServer
{
"host": "mailpit",
"port": "1025",
"from": "keycloak@example.com",
"auth": "true",
"user": "kcmailer",
"password": "**********"
}

Import that into a different realm or a fresh server and ten asterisks are what gets stored and what gets sent to the relay. The console then shows a password field that looks filled in, and every email fails:

jakarta.mail.AuthenticationFailedException: 535 5.7.8 Authentication credentials invalid

Updating an existing realm with the mask is safe — Keycloak reads it as "keep the stored value", which is why round-tripping the realm through kcadm update or the console does not break anything. The mask only destroys information when there is no stored value to fall back on. So: treat the SMTP password as a secret your deployment pipeline injects after import, the same way you would a database password, and never assume an export is a complete backup of a realm's mail configuration.

Users say they never got the email​

In order of likelihood:

  1. It failed and nobody was told. Check for SEND_RESET_PASSWORD_ERROR or SEND_VERIFY_EMAIL_ERROR in the realm's events, and KC-SERVICES0026 / KC-SERVICES0029 in the server log. The page the user saw said it worked.
  2. The relay accepted it and then dropped it. Keycloak's job ends at 250 Ok: queued. From there it is your provider's suppression list, bounce log and spam placement.
  3. The user has no email address, or the wrong one. GET /users?username=… and look.
  4. It went to the wrong realm's relay. SMTP is per realm. A realm you created by copying another one does not inherit it.

Turn on the SMTP conversation​

debug dumps the entire exchange into the server log — far more useful than any error message Keycloak surfaces:

kcadm update realms/prod -s 'smtpServer.debug=true'
DEBUG SMTP: trying to connect to host "relay.example.com", port 587, isSSL false
220 relay.example.com ESMTP ready
EHLO keycloak-0
250-STARTTLS
250-AUTH LOGIN PLAIN
STARTTLS
220 2.0.0 Ready to start TLS
DEBUG SMTP: protocolConnect login, host=relay.example.com, user=kcmailer, password=<non-null>
DEBUG SMTP: Attempt to authenticate using mechanisms: LOGIN PLAIN DIGEST-MD5 NTLM XOAUTH2

That transcript answers most questions directly: whether STARTTLS was advertised, which AUTH mechanisms the server offers, and whether Keycloak thinks it has a password at all. Turn it off when you are done — it is verbose, and it writes your SMTP conversation to a log that is probably shipped somewhere.

Email sending is slow​

Three timeouts, all per realm, all in milliseconds:

API keyPropertyDefault
connectionTimeoutmail.smtp.connectiontimeout—
timeoutmail.smtp.timeout10000
writeTimeoutmail.smtp.writetimeout—

They work: with connectionTimeout=3000 and the host pointed at an unroutable address, the call returned in 3.1 seconds with MailConnectException: Couldn't connect to host, port: …; timeout 3000. Keycloak sends mail on the request thread, so a relay that takes ten seconds to fail makes the login and registration pages take ten seconds to fail with it. Set these to something smaller than your users' patience.

Next steps​